openapi: "3.0.3"
info:
  title: Mandaitor Delegation Mandate Registry API
  version: "1.0.0"
  description: |
    Neutral, trustworthy infrastructure for verifiable delegated authority
    between humans and AI agents.
  contact:
    name: Mandaitor API Support
    email: api@mandaitor.io
  license:
    name: Proprietary
    url: https://mandaitor.io/legal/license

servers:
  - url: https://api.mandaitor.io/v1
    description: Production
  - url: https://api.dev.mandaitor.io/v1
    description: Development

security:
  - ApiKeyAuth: []
  - BearerAuth: []

tags:
  - name: Mandates
    description: Core mandate lifecycle operations
  - name: Verification
    description: Real-time action authorization
  - name: Decisions
    description: "**Preview.** Request-bound decisions: `/verify`'s successor for an integrator whose enforcement point performs an agent's operation itself"
  - name: Events
    description: Immutable audit trail
  - name: Onboarding
    description: Tenant access request and activation
  - name: Admin
    description: Admin-only operations
  - name: Widget Config
    description: Tenant widget configuration
  - name: EUDI Wallet
    description: eIDAS 2.0 EUDI Wallet identity verification (OpenID4VP)
  - name: Identity
    description: Identity provider configuration, token exchange, and SCIM provisioning
  - name: SCIM
    description: SCIM 2.0 user provisioning (Okta/Entra ID lifecycle management)
  - name: Billing
    description: Pay-per-use billing, usage tracking, and payment management
  - name: Public
    description: Unauthenticated public endpoints
  - name: Drift Monitor
    description: "**@experimental** Behavioural drift monitoring for delegated agents"
  - name: VI Credential
    description: "Verifiable Intent (VI) compatibility layer for cross-platform credential exchange"
  - name: Agent Registry
    description: "Agent Identity Registry — DID-based identity management for AI agents"
  - name: MCP Authorization
    description: "MCP Authorization Server — OAuth sessions for AI agents, for the MCP server and the execution API"
  - name: Consent
    description: "Human-in-the-Loop consent approval flows with auto-approval policies"
  - name: Agent Marketplace
    description: "Agent discovery, catalog listings, capability matching, and reviews"
  - name: Partner Workspace
    description: "Engagement workspace — knowledge catalog (media assets, documents, signed delivery) and per-phase engagement artifacts"
  - name: Organisation
    description: "Organisational accountability structure — units, owners and risk classification for governance reporting"
  - name: Governance
    description: "Aggregated oversight indicators for management roles — portfolio posture rather than individual mandates"
  - name: Execution
    description: "The execution platform — business actions, the tool registry, each tenant's execution policy, and executions"
  - name: MCP Server
    description: "The MCP server (Streamable HTTP): an agent's MCP client lists the tools its mandates could back and calls them, each call an execution"

# ═══════════════════════════════════════════════════════════════
# PATHS
# ═══════════════════════════════════════════════════════════════

paths:
  # ─── Core Mandates ──────────────────────────────────────────

  /mandates:
    post:
      operationId: createMandate
      summary: Create a new mandate
      tags: [Mandates]
      description: |
        Creates a new delegation mandate between a principal and a delegate.
        The mandate defines the scope of delegated authority, optional constraints,
        and an expiration date. A KMS-signed audit event is emitted on success.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateMandateRequest"
      responses:
        "201":
          description: |
            Mandate created.

            When the request carried constraint keys that no code path
            evaluates, the response also names them in
            `unsupported_constraints`. The mandate is created either way — the
            keys are stored, they simply do not restrict anything. Treat a
            non-empty `unsupported_constraints` as a bug in your mandate.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Mandate"
                  - type: object
                    properties:
                      unsupported_constraints:
                        type: array
                        description: >-
                          Constraint keys stored on this mandate that nothing
                          evaluates. Absent when there are none.
                        items:
                          type: string
                      unsupported_constraints_message:
                        type: string
                        description: Human-readable form of the same warning.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

    get:
      operationId: listMandates
      summary: List mandates (paginated)
      tags: [Mandates]
      description: |
        Returns a paginated list of mandates for the authenticated tenant.
        Supports optional filtering by status and cursor-based pagination.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [DRAFT, ACTIVE, SUSPENDED, REVOKED, EXPIRED]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated list of mandates
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/Mandate"
                  next_cursor:
                    type: string
                    nullable: true
                    description: Base64url-encoded cursor for the next page
        "401":
          $ref: "#/components/responses/Unauthorized"

  /mandates/{id}:
    get:
      operationId: getMandate
      summary: Get mandate by ID
      tags: [Mandates]
      description: |
        Retrieves a single mandate by its ID. Returns the full mandate record
        including principal, delegate, scope, constraints, and proof metadata.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      responses:
        "200":
          description: Mandate details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Mandate"
        "404":
          $ref: "#/components/responses/NotFound"

  /mandates/{id}/revoke:
    post:
      operationId: revokeMandate
      summary: Revoke a mandate
      tags: [Mandates]
      description: |
        Permanently revokes a mandate. Only active or suspended mandates can be
        revoked. An optional reason can be provided. A KMS-signed audit event is emitted.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  example: "Contract terminated"
      responses:
        "200":
          description: Mandate revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusChange"
        "409":
          $ref: "#/components/responses/Conflict"

  /mandates/{id}/suspend:
    post:
      operationId: suspendMandate
      summary: Suspend a mandate
      tags: [Mandates]
      description: |
        Temporarily suspends an active mandate. Suspended mandates can be
        reactivated later. An optional reason can be provided.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  example: "Under review"
      responses:
        "200":
          description: Mandate suspended
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusChange"
        "409":
          $ref: "#/components/responses/Conflict"

  /mandates/{id}/reactivate:
    post:
      operationId: reactivateMandate
      summary: Reactivate a suspended mandate
      tags: [Mandates]
      description: |
        Reactivates a previously suspended mandate, returning it to ACTIVE status.
        Only mandates in SUSPENDED state can be reactivated.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      responses:
        "200":
          description: Mandate reactivated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusChange"
        "409":
          $ref: "#/components/responses/Conflict"

  # ─── Approval Workflow ──────────────────────────────────────

  /mandates/{id}/approve:
    post:
      operationId: approveMandate
      summary: Approve a pending mandate
      tags: [Mandates]
      description: |
        Approves a mandate that is in PENDING_APPROVAL state, transitioning it
        to ACTIVE. Only mandates created with `require_approval: true` enter
        this state. An audit event (MANDATE_APPROVED) is emitted.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional reason/notes for the approval
      responses:
        "200":
          description: Mandate approved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusChange"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /mandates/{id}/reject:
    post:
      operationId: rejectMandate
      summary: Reject a pending mandate
      tags: [Mandates]
      description: |
        Rejects a mandate that is in PENDING_APPROVAL state, transitioning it
        to REVOKED. An audit event (MANDATE_REJECTED) is emitted.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Reason for rejection
      responses:
        "200":
          description: Mandate rejected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusChange"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  # ─── Verification ───────────────────────────────────────────

  /verify:
    post:
      operationId: verifyAction
      summary: Verify if an action is authorized by a mandate
      tags: [Verification]
      description: |
        Performance-critical endpoint. Target: < 50ms p99 latency (without PoM),
        < 200ms p99 (with PoM). Returns ALLOW or DENY with optional escalation
        metadata and an optional Proof-of-Mandate Verifiable Credential.
      parameters:
        - name: pom
          in: query
          description: |
            Request a Proof-of-Mandate Verifiable Credential in the response.
            The VC is an SD-JWT signed by the Mandaitor issuer DID.
          required: false
          schema:
            type: string
            enum: [sd-jwt-vc]
        - name: drift
          in: query
          description: |
            **@experimental** Request drift detection signals in the response.
            When `true`, the response includes a `semantic_signals` object with
            multi-dimensional drift scoring. Drift signals are advisory only and
            NEVER affect the ALLOW/DENY decision. Requires the tenant to have
            drift detection enabled.
          required: false
          schema:
            type: boolean
            default: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyRequest"
      responses:
        "200":
          description: Verification result with optional Proof-of-Mandate VC and experimental semantic signals
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/VerifyResponseWithPoM"
                  - $ref: "#/components/schemas/VerifyResponseWithSemantics"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: |
            The decision could not be recorded in the audit log, so no decision is
            given (`error: AUDIT_UNAVAILABLE`). Decision events are recorded on a
            durable queue before the response is sent and appended to the mandate's
            hash chain within seconds; this response means neither the queue nor the
            chain accepted the record. Retry after the `Retry-After` seconds; the
            retry gets a fresh decision and event id.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ─── Request-bound decisions (Preview) ─────────────────────

  /decisions:
    post:
      operationId: requestDecision
      summary: Decide one operation your enforcement point is about to perform
      tags: [Decisions]
      description: |
        **Preview.** `/verify`'s successor for an integrator whose own
        enforcement point performs an agent's operation. Ask before you act,
        with the tenant's API key (the `verify` scope), never from the agent:
        name the agent by its DID, the taxonomy business action, the one
        resource it acts on, and the facts you read from your own system.
        Mandaitor decides as it decides an execution: every `ACTIVE` mandate
        naming the agent counts, a `DENY` mandate wins, and an `ALLOW`
        mandate counts only if a person created or approved it and it names
        the action exactly. Its time window, `max_amount`, cumulative limits,
        `amount_above` and `require_mfa` are checked on your facts; a mandate
        that limits amounts needs an `amount` fact, as money.

        **ALLOW** carries `authorization`: a JWS (`typ`
        `mandaitor-authorization+jwt`, PS256) signed with the key the
        issuer's DID document lists as `#authorization-key-1`. It is bound to
        the request's digest, to `audience` and to five minutes at most,
        never past the mandate's expiry. Redeem it with
        `POST /decisions/{id}/redemption` immediately before you act; act
        only if the redemption succeeds. It counts against the mandate's
        cumulative limits when it is given, whether or not it is redeemed.

        **REQUIRE_APPROVAL** and **DENY** carry no authorization. A decision
        request raises no approval: when a person would have to approve, the
        request fails closed.

        The request digest is the SHA-256, `sha-256:` and base64url, of the
        RFC 8785 form of `{contract, tenant_id, agent, action, resource,
        facts, audience}`; `mandate_id` and `purpose` are not part of it.
        The SDK computes it as `decisionRequestDigest`.

        Every answer is recorded on the audit chain before it is given. When
        it cannot be, nothing is given: 503 `AUDIT_UNAVAILABLE`. Each answer
        counts 2 units against the monthly allowance, an ALLOW or not.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DecisionRequest"
      responses:
        "200":
          description: The decision. Only an `ALLOW` carries an authorization.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Decision"
        "400":
          description: |
            `INVALID_REQUEST`: the body is not a valid
            `mandaitor.decision-request/v1` (an unknown key, a resource with
            `*` or a `{placeholder}`, an amount that is not money, more than 32
            facts). `errors` lists each problem.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API key lacks the `verify` scope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            `AUDIT_UNAVAILABLE`: the decision could not be recorded on the
            audit chain, so none is given. Retry after `Retry-After`; the
            retry is decided afresh.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionError"

  /decisions/{id}/redemption:
    post:
      operationId: redeemDecision
      summary: Redeem an authorization, once, immediately before acting on it
      tags: [Decisions]
      description: |
        **Preview.** Send the digest of the operation you are about to
        perform, computed by you, never copied from the answer. The
        redemption succeeds once, and only:
        - for the request the authorization was given for;
        - before its `exp`;
        - while the agent is active and has not been suspended or revoked
          since, and the mandate it rests on is active and unchanged since;
        - while no kill switch runs on the tenant, and none was requested
          since it was given.

        Act only on a 200. Every 409 means do not act, including
        `ALREADY_REDEEMED` after a retry whose first answer was lost: ask for
        a new decision. Every redemption, refused or not, is recorded on the
        audit chain before it is answered; when it cannot be, the answer is
        503 and the authorization is left as it was. Redemptions are not
        counted against the allowance.
      parameters:
        - name: id
          in: path
          required: true
          description: The decision's `decision_id`, the authorization's `jti`.
          schema:
            type: string
            pattern: "^dec_[0-9A-Z]{26}$"
            maxLength: 30
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DecisionRedemptionRequest"
      responses:
        "200":
          description: Redeemed. Act now; the authorization is spent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionRedemption"
        "400":
          description: "`INVALID_REQUEST`: the body is not `{ request_digest }`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API key lacks the `verify` scope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: "`DECISION_NOT_FOUND`: no authorization of this tenant has this id."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionError"
        "409":
          description: |
            Refused; do not act. `DIGEST_MISMATCH`: the authorization was
            given for another request. `ALREADY_REDEEMED`: it was redeemed
            before. `AUTHORIZATION_EXPIRED`: past its `exp`.
            `AGENT_NOT_ACTIVE`: the agent was suspended or revoked since it
            was given. `MANDATE_NOT_ACTIVE`: the mandate it rests on is no
            longer active, or changed since. `KILL_SWITCH`: the tenant's
            kill switch is running, or was requested since it was given.
            `event_id` names the refusal on the audit chain.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionError"
        "503":
          description: |
            `AUDIT_UNAVAILABLE`: the redemption could not be recorded, so it
            was taken back. Retry after `Retry-After`.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionError"

  # ─── EUDI Wallet (eIDAS 2.0 / OpenID4VP) ───────────────────

  /eudi/sessions:
    post:
      operationId: initiateEudiSession
      summary: Initiate EUDI Wallet verification session
      tags: [EUDI Wallet]
      description: |
        Creates a new OpenID4VP session for EUDI Wallet identity verification.
        Returns a QR code URI (cross-device) and deep link URI (same-device)
        that the user scans/opens with their EUDI Wallet.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                presentation_definition_id:
                  type: string
                  description: ID of the presentation definition to use
                  enum:
                    - mandaitor-pid-minimal
                    - mandaitor-pid-standard
                    - mandaitor-pid-delegation
                  default: mandaitor-pid-standard
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EudiSessionResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /eudi/sessions/{sessionId}:
    get:
      operationId: getEudiSessionStatus
      summary: Poll EUDI session status
      tags: [EUDI Wallet]
      description: |
        Polls the status of an EUDI Wallet verification session.
        Returns the resolved identity when the session is completed.
      parameters:
        - name: sessionId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Session status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EudiSessionStatus"
        "404":
          $ref: "#/components/responses/NotFound"

  /eudi/sessions/{sessionId}/response:
    post:
      operationId: eudiSessionCallback
      summary: Receive VP Token from EUDI Wallet
      tags: [EUDI Wallet]
      security: []
      x-auth-exemption-reason: >-
        EUDI Wallet posts the VP Token via direct_post after user consent; the caller holds no
        platform credential, and the response is bound to the session id and the presentation
        signature.
      description: |
        Receives the VP Token from the EUDI Wallet via direct_post response mode.
        This endpoint is called by the wallet after user consent. No authentication
        required as the wallet POSTs directly.
      parameters:
        - name: sessionId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vp_token]
              properties:
                vp_token:
                  type: string
                  description: The VP Token (SD-JWT VC compact serialization)
                presentation_submission:
                  type: object
                  description: DIF Presentation Submission mapping
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [vp_token]
              properties:
                vp_token:
                  type: string
                presentation_submission:
                  type: string
      responses:
        "200":
          description: VP Token accepted and verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [COMPLETED]
                  session_id:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"

  /eudi/request/{requestId}:
    get:
      operationId: getEudiRequestObject
      summary: Serve OpenID4VP Authorization Request Object
      tags: [EUDI Wallet]
      security: []
      x-auth-exemption-reason: >-
        EUDI Wallet fetches the Authorization Request Object after scanning the QR code; the
        object itself is a signed JWT, so its integrity does not depend on caller
        authentication.
      description: |
        Serves the signed JWT Authorization Request Object that the EUDI Wallet
        fetches after scanning the QR code. Returns the JWT directly with
        content type application/oauth-authz-req+jwt.
      parameters:
        - name: requestId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Signed JWT Request Object
          content:
            application/oauth-authz-req+jwt:
              schema:
                type: string
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Identity Provider Configuration ────────────────────────

  /tenants/{id}/identity-providers:
    get:
      operationId: getIdpConfig
      summary: Get tenant identity provider configuration
      tags: [Identity]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Identity provider configuration
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantIdpConfig"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: updateIdpConfig
      summary: Update tenant identity provider configuration
      tags: [Identity]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantIdpConfigUpdate"
      responses:
        "200":
          description: Configuration updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantIdpConfig"
        "400":
          $ref: "#/components/responses/BadRequest"

  # ─── SCIM 2.0 Provisioning ────────────────────────────────

  /scim/v2/Users:
    get:
      operationId: scimListUsers
      summary: List provisioned users
      tags: [SCIM]
      parameters:
        - name: startIndex
          in: query
          schema:
            type: integer
            default: 1
        - name: count
          in: query
          schema:
            type: integer
            default: 100
      responses:
        "200":
          description: SCIM ListResponse
          content:
            application/scim+json:
              schema:
                type: object
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: scimCreateUser
      summary: Provision a new user
      tags: [SCIM]
      requestBody:
        required: true
        content:
          application/scim+json:
            schema:
              type: object
      responses:
        "201":
          description: User created
          content:
            application/scim+json:
              schema:
                type: object
        "400":
          $ref: "#/components/responses/BadRequest"

  /scim/v2/Users/{userId}:
    get:
      operationId: scimGetUser
      summary: Get a provisioned user
      tags: [SCIM]
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: SCIM User
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: scimReplaceUser
      summary: Replace a user
      tags: [SCIM]
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/scim+json:
            schema:
              type: object
      responses:
        "200":
          description: User updated
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: scimUpdateUser
      summary: Partially update a user
      tags: [SCIM]
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/scim+json:
            schema:
              type: object
      responses:
        "200":
          description: User updated
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: scimDeleteUser
      summary: Deprovision a user (revokes all mandates)
      tags: [SCIM]
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: User deleted
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Health ────────────────────────────────────────────────

  /health:
    get:
      operationId: getHealth
      summary: Health/status check
      tags: [Public]
      security: []
      x-auth-exemption-reason: >-
        Liveness endpoint for monitoring, load balancers and Trust Center embedding; exposes no
        tenant data.
      description: |
        Returns service health status. No sensitive data is exposed.
        Suitable for monitoring, load balancers, and Trust Center embedding.
      responses:
        "200":
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                required: [status, timestamp, version]
                properties:
                  status:
                    type: string
                    enum: [ok]
                    description: Service status
                  timestamp:
                    type: string
                    format: date-time
                    description: Current server time (ISO 8601)
                  version:
                    type: string
                    description: Build version identifier
                    example: "1.2.3-beta.1"
                  stage:
                    type: string
                    description: Deployment stage
                    example: "prod"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Too Many Requests"
        "503":
          description: Service is unavailable or degraded
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "degraded"
                  error:
                    type: string

  # ─── DID Document ──────────────────────────────────────────

  /.well-known/did.json:
    get:
      operationId: getDidDocument
      summary: Resolve the Mandaitor issuer DID document
      tags: [Public]
      security: []
      x-auth-exemption-reason: >-
        W3C DID Document for the issuer DID. Public by design: it carries the public key relying
        parties need in order to verify Proof-of-Mandate credentials.
      description: |
        Returns the W3C DID Document for the Mandaitor issuer DID
        (did:web:api.mandaitor.io). Contains the public key used to
        verify Proof-of-Mandate SD-JWT VCs.
      responses:
        "200":
          description: DID Document
          content:
            application/did+json:
              schema:
                type: object
                properties:
                  "@context":
                    type: array
                    items:
                      type: string
                  id:
                    type: string
                    example: "did:web:api.mandaitor.io"
                  verificationMethod:
                    type: array
                    items:
                      type: object
                  authentication:
                    type: array
                    items:
                      type: string
                  assertionMethod:
                    type: array
                    items:
                      type: string
                  service:
                    type: array
                    items:
                      type: object
        "404":
          $ref: "#/components/responses/NotFound"

  /did.json:
    get:
      operationId: getDidDocumentSpecPath
      summary: Resolve the Mandaitor issuer DID document (spec-compliant path)
      tags: [Public]
      security: []
      x-auth-exemption-reason: >-
        Spec-compliant resolution path for the same public issuer DID Document (did:web path
        DID).
      description: |
        Spec-compliant resolution path for the issuer DID. The issuer DID is a
        *path* DID (`did:web:api.mandaitor.io:v1`), which per the W3C did:web
        specification resolves to `https://api.mandaitor.io/v1/did.json` — this
        endpoint. Returns the same W3C DID Document as `/.well-known/did.json`,
        which is kept as an alias.
      responses:
        "200":
          description: DID Document
          content:
            application/did+json:
              schema:
                type: object
                properties:
                  "@context":
                    type: array
                    items:
                      type: string
                  id:
                    type: string
                    example: "did:web:api.mandaitor.io:v1"
                  verificationMethod:
                    type: array
                    items:
                      type: object
                  authentication:
                    type: array
                    items:
                      type: string
                  assertionMethod:
                    type: array
                    items:
                      type: string
                  service:
                    type: array
                    items:
                      type: object
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Trust Signals ──────────────────────────────────

  /.well-known/trust.json:
    get:
      operationId: getTrustSignals
      summary: Public trust signals for machine-readable trust metadata
      tags: [Public]
      security: []
      x-auth-exemption-reason: >-
        Machine-readable public trust posture (region, partition, issuer DID, supported
        standards). Published so third parties can verify it without an account.
      description: |
        Returns machine-readable trust metadata including region, partition,
        issuer DID, data residency claims, and supported open standards.
        Unauthenticated and cache-friendly (5-minute client, 10-minute CDN).
      responses:
        "200":
          description: Trust signals
          content:
            application/json:
              schema:
                type: object
                required: [schema_version, region, partition, issuer_did, did_document_url]
                properties:
                  schema_version:
                    type: string
                    example: "1.0.0"
                  region:
                    type: string
                    description: AWS region where the service operates
                    example: "eusc-de-east-1"
                  partition:
                    type: string
                    description: AWS partition (aws, aws-eusc)
                    example: "aws-eusc"
                  issuer_did:
                    type: string
                    description: DID of the Mandaitor VC issuer
                    example: "did:web:api.mandaitor.io"
                  did_document_url:
                    type: string
                    format: uri
                    description: URL to resolve the issuer DID document
                    example: "https://api.mandaitor.io/.well-known/did.json"
                  data_residency:
                    type: object
                    properties:
                      primary_region:
                        type: string
                      partition:
                        type: string
                      jurisdiction:
                        type: string
                        example: "EU (European Sovereign Cloud)"
                  open_standards:
                    type: array
                    items:
                      type: string
                    description: List of open standards implemented
                  build_version:
                    type: string
                  stage:
                    type: string
                  timestamp:
                    type: string
                    format: date-time
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Too Many Requests"
        "503":
          description: Trust signals temporarily unavailable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string

  # ─── Events ─────────────────────────────────────────────────

  /mandates/{id}/events:
    get:
      operationId: getMandateEvents
      summary: Get audit events for a mandate
      tags: [Events]
      description: |
        Returns a paginated list of audit events for a specific mandate.
        Events form an immutable, hash-chained audit trail.
      parameters:
        - $ref: "#/components/parameters/MandateId"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated list of events
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/AuditEvent"
                  next_cursor:
                    type: string
                    nullable: true
                    description: Base64url-encoded cursor for the next page
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /mandates/{id}/instruction:
    get:
      operationId: getMandateInstruction
      summary: Retrieve the decrypted user instruction for a mandate
      tags: [Mandates]
      description: |
        Returns the decrypted original user instruction that was provided when
        the mandate was created. Requires authentication and tenant ownership.
        An INSTRUCTION_REVEALED audit event is emitted on each access.

        Only available for mandates that were created with a Verifiable User
        Instruction (VUI). Returns 404 if no instruction exists.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      responses:
        "200":
          description: Decrypted instruction
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InstructionResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /mandates/{id}/evidence-pack:
    get:
      operationId: getEvidencePack
      summary: Export a court-ready evidence pack
      tags: [Evidence]
      description: |
        Exports a comprehensive, court-ready evidence pack for a mandate containing:
        - Mandate snapshot at export time
        - Hash-chained audit event log
        - SD-JWT proof tokens from verification events
        - Case log hash (SHA-256 fingerprint over canonical event chain)
        - Export manifest with requester attribution, coverage, and recomputable digests
        - Immutable `EVIDENCE_PACK_EXPORTED` audit event recorded for the export operation
        - Issuer DID document reference for independent verification

        The `case_log_hash` is a deterministic SHA-256 digest computed over the
        canonical JSON representation of the event chain, enabling tamper detection.

        The `export_manifest` binds the mandate snapshot, event chain, proof tokens,
        and export envelope to recomputable digests so offline reviewers can detect
        tampering in the export artifact.

        The `schema_version` field allows forward-compatible evolution of the pack format.
      parameters:
        - $ref: "#/components/parameters/MandateId"
        - name: event_id
          in: query
          description: Scope the evidence pack to a specific verification event
          schema:
            type: string
      responses:
        "200":
          description: Evidence pack exported successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvidencePack"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /events:
    get:
      operationId: listEvents
      summary: Query all events for the tenant
      tags: [Events]
      description: |
        Queries all audit events for the authenticated tenant, filtered by mandate_id.
        Supports optional event_type filtering and cursor-based pagination.
      parameters:
        - name: mandate_id
          in: query
          required: true
          schema:
            type: string
          description: Mandate ID to filter events by
        - name: event_type
          in: query
          schema:
            type: string
            enum:
              - MANDATE_CREATED
              - MANDATE_SUSPENDED
              - MANDATE_REACTIVATED
              - MANDATE_REVOKED
              - VERIFICATION_ALLOWED
              - VERIFICATION_DENIED
              - ESCALATION_TRIGGERED
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated list of events
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/AuditEvent"
                  next_cursor:
                    type: string
                    nullable: true
                    description: Base64url-encoded cursor for the next page
        "401":
          $ref: "#/components/responses/Unauthorized"

  /events/{id}:
    get:
      operationId: getEvent
      summary: Get a specific event by ID
      tags: [Events]
      description: |
        Retrieves a single audit event by its ID. Requires the mandate_id query
        parameter to locate the event in the partition.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: "evt_01HXYZ..."
        - name: mandate_id
          in: query
          required: true
          schema:
            type: string
          description: Mandate ID the event belongs to
      responses:
        "200":
          description: Event details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditEvent"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Onboarding ─────────────────────────────────────────────

  /onboarding/request:
    post:
      operationId: requestAccess
      summary: Submit an access request (no auth required)
      tags: [Onboarding, Public]
      description: |
        Submits a new tenant access request. No authentication required.
        The request enters PENDING state and must be approved by an admin.
      security: []
      x-auth-exemption-reason: >-
        Access-request intake. The caller has no tenant credential yet by definition; the
        request lands in PENDING and grants nothing until an admin approves it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccessRequest"
      responses:
        "201":
          description: Access request submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id:
                    type: string
                  status:
                    type: string
                    example: "PENDING"
                  message:
                    type: string

        "400":
          $ref: "#/components/responses/BadRequest"
  /admin/onboarding:
    get:
      operationId: listOnboardingRequests
      summary: List onboarding access requests (admin only)
      tags: [Admin]
      description: |
        Lists onboarding access requests. Supports optional status filtering
        (PENDING, APPROVED, REJECTED) and cursor-based pagination.
        Requires Cognito JWT with mandaitor-admins group membership.
      security:
        - BearerAuth: []
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [PENDING, APPROVED, REJECTED]
          description: Filter by request status
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated list of onboarding requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests:
                    type: array
                    items:
                      type: object
                      properties:
                        request_id:
                          type: string
                        company_name:
                          type: string
                        contact_name:
                          type: string
                        contact_email:
                          type: string
                          format: email
                        use_case:
                          type: string
                        industry:
                          type: string
                          enum: [construction, real_estate, venture_capital, other]
                        website:
                          type: string
                          format: uri
                        lead_source:
                          type: string
                          description: Marketing or referral source associated with the beta request.
                        beta_track:
                          type: string
                          description: Closed-beta routing segment used for the request.
                        asset:
                          type: string
                          description: Specific public asset or CTA entry point that produced the request.
                        status:
                          type: string
                          enum: [PENDING, APPROVED, REJECTED]
                        submitted_at:
                          type: string
                          format: date-time
                        reviewed_at:
                          type: string
                          format: date-time
                        reviewed_by:
                          type: string
                        tenant_id:
                          type: string
                  next_cursor:
                    type: string
                    nullable: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Admin group membership required
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /admin/onboarding/{requestId}/{action}:
    post:
      operationId: reviewOnboardingRequest
      summary: Approve or reject an access request
      description: |
        Processes an admin action on an onboarding request.
        Supported values for `{action}`: `approve`, `reject`.
      tags: [Admin]
      parameters:
        - name: requestId
          in: path
          required: true
          schema:
            type: string
        - name: action
          in: path
          required: true
          schema:
            type: string
            enum: [approve, reject]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
      responses:
        "200":
          description: Request reviewed
        "201":
          description: Tenant activated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantRecord"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /admin/tenants/{id}:
    get:
      operationId: getTenantDetail
      summary: Get tenant detail (admin)
      tags: [Admin]
      description: |
        Returns a single tenant's config, linked onboarding metadata, API key
        count, and mandate status counts (capped at the first 600 mandates).

        Requires Cognito JWT with `mandaitor-admins` group membership.
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: "tnt_ABC123"
      responses:
        "200":
          description: Tenant detail payload
          content:
            application/json:
              schema:
                type: object
                required:
                  - tenant_id
                  - display_name
                  - status
                  - api_key_count
                  - mandate_stats
                properties:
                  tenant_id:
                    type: string
                  display_name:
                    type: string
                  contact_name:
                    type: string
                    nullable: true
                  contact_email:
                    type: string
                    nullable: true
                  status:
                    type: string
                    example: "ACTIVE"
                  created_at:
                    type: string
                    format: date-time
                  usage_plan:
                    type: object
                    nullable: true
                    additionalProperties: true
                    description: >-
                      The tenant's API Gateway usage plan. `tier` is the field
                      that decides the throttle; the numeric fields are legacy,
                      present only on rows written before 2026-09-10, and are
                      read by nothing. The monthly volume ceiling for free-tier
                      accounts is reported by `GET /tenants/{id}/usage` under
                      `allowance`, not here.
                    properties:
                      tier:
                        type: string
                        enum: [standard, enterprise]
                      rate_limit:
                        type: number
                        nullable: true
                        deprecated: true
                      quota_limit:
                        type: number
                        nullable: true
                        deprecated: true
                      quota_period:
                        type: string
                        deprecated: true
                        nullable: true
                  api_key_count:
                    type: integer
                  mandate_stats:
                    type: object
                    required: [total, active, suspended, revoked, expired, draft]
                    properties:
                      total:
                        type: integer
                      active:
                        type: integer
                      suspended:
                        type: integer
                      revoked:
                        type: integer
                      expired:
                        type: integer
                      draft:
                        type: integer
                  onboarding:
                    type: object
                    nullable: true
                    properties:
                      request_id:
                        type: string
                      use_case:
                        type: string
                        nullable: true
                      website:
                        type: string
                        nullable: true
                      submitted_at:
                        type: string
                        format: date-time
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      reviewed_by:
                        type: string
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not in the mandaitor-admins group
        "404":
          $ref: "#/components/responses/NotFound"

  /admin/tenants/{id}/kill-switch:
    post:
      operationId: tenantEmergencyKillSwitch
      summary: Emergency kill-switch — stop a tenant's executions and revoke its mandates
      tags: [Admin]
      description: |
        **EMERGENCY OPERATION** — Stops what a tenant's agents may do and
        revokes its ACTIVE, SUSPENDED and PENDING_APPROVAL mandates. This is
        an irreversible bulk operation intended for security incidents,
        compliance breaches, or contract terminations.

        It takes effect on the execution path before it answers: every tool
        the tenant has enabled is disabled, so from then on no execution is
        created, approved or dispatched, and the MCP server lists no tool.
        The tenant enables its tools again, one by one, once the incident is
        over; the execution lists them as `tools_disabled`. While it runs, a
        request-bound decision is `DENY` with `KILL_SWITCH`, and an
        authorization given before it is not redeemed.

        The rest runs asynchronously. The call records an execution, queues
        it and returns `202 Accepted` with an `execution_id`. A worker then
        stops the tenant's executions waiting for an approval or for their
        dispatch, each `DENIED` with `KILL_SWITCH` (one already dispatched
        finishes: nothing recalls it), and revokes the mandates page by
        page, checkpointing its progress on the execution, which
        `GET /admin/tenants/{id}/kill-switch/{executionId}` reports until the
        status is `COMPLETED` or `FAILED`. Executions for one tenant run one
        at a time; calling again while one is running queues a second
        execution that finds nothing left to stop.

        Requires Cognito JWT with `mandaitor-admins` group membership. A
        `MANDATE_REVOKED` audit event is emitted per mandate with
        `details.kill_switch_triggered = true` and `details.execution_id`,
        and an `EXECUTION_DENIED` event per execution stopped, with
        `details.stage = kill_switch` and `details.kill_switch_execution_id`.
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: "tnt_ABC123"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  maxLength: 1000
                  description: Mandatory reason for the emergency kill-switch
                  example: "Security incident — unauthorized access detected"
      responses:
        "202":
          description: Kill-switch queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KillSwitchAccepted"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    get:
      operationId: listTenantKillSwitchExecutions
      summary: List recent kill-switch executions for a tenant
      tags: [Admin]
      description: |
        The twenty most recent kill-switch executions for the tenant, newest
        first, with their status and counts. Requires Cognito JWT with
        `mandaitor-admins` group membership.
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: "tnt_ABC123"
      responses:
        "200":
          description: Recent executions
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id:
                    type: string
                  executions:
                    type: array
                    items:
                      $ref: "#/components/schemas/KillSwitchExecution"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /admin/tenants/{id}/kill-switch/{executionId}:
    get:
      operationId: getTenantKillSwitchExecution
      summary: Get the status of a kill-switch execution
      tags: [Admin]
      description: |
        Progress of one kill-switch execution: status, how many mandates were
        revoked, skipped or failed so far, and the last error if any. Poll
        this after a `202` until `status` is `COMPLETED` or `FAILED`.
        Requires Cognito JWT with `mandaitor-admins` group membership.
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: "tnt_ABC123"
        - name: executionId
          in: path
          required: true
          schema:
            type: string
            example: "ks_01HXYZ..."
      responses:
        "200":
          description: Execution status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KillSwitchExecution"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /admin/system-mode:
    get:
      operationId: getSystemMode
      summary: Get current system operational mode
      tags: [Admin]
      description: |
        Returns the current system mode (growth, sustain, maintenance, frozen)
        and associated metadata. Requires Cognito JWT with `mandaitor-admins`
        group membership.
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Current system mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SystemMode"
        "401":
          $ref: "#/components/responses/Unauthorized"

    post:
      operationId: setSystemMode
      summary: Set system operational mode
      tags: [Admin]
      description: |
        Changes the system operational mode. This immediately affects all API
        endpoints:

        - **growth** — Normal operation. All features active.
        - **sustain** — Normal operation. No new features (policy only).
        - **maintenance** — All authenticated endpoints return 503.
        - **frozen** — Write endpoints return 503. Reads still work.

        Mode changes propagate within ~10 seconds via in-memory cache TTL.
        Requires Cognito JWT with `mandaitor-admins` group membership.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode:
                  type: string
                  enum: [growth, sustain, maintenance, frozen]
                  example: "maintenance"
                reason:
                  type: string
                  example: "CDK stack update — estimated 30 minutes"
                estimated_duration_minutes:
                  type: integer
                  example: 30
      responses:
        "200":
          description: System mode updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SystemMode"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ─── Auth Proxy (public, no Cognito authorizer) ────────────────────────────
  /admin/auth/{action}:
    post:
      operationId: adminAuthProxy
      summary: Authentication proxy for Cognito operations
      tags: [Admin]
      security: []
      x-auth-exemption-reason: >-
        Authentication proxy to Cognito. This is the endpoint that issues a credential, so it
        cannot require one.
      description: |
        Proxies authentication requests to Cognito from the browser, bypassing
        the ESC Cognito IDP endpoint DNS resolution issue. The `{action}`
        path parameter determines the operation:

        - **login** — `InitiateAuth` with `USER_PASSWORD_AUTH`
        - **respond** — `RespondToAuthChallenge` (NEW_PASSWORD_REQUIRED, MFA)
        - **refresh** — `REFRESH_TOKEN_AUTH`
        - **sign-out** — `GlobalSignOut`
        - **forgot-password** — `ForgotPassword`
        - **confirm-forgot-password** — `ConfirmForgotPassword`
      parameters:
        - name: action
          in: path
          required: true
          schema:
            type: string
            enum: [login, respond, refresh, sign-out, forgot-password, confirm-forgot-password]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                Request body varies by action:
                - **login**: `{ email, password }`
                - **respond**: `{ challengeName, session, responses }`
                - **refresh**: `{ refreshToken }`
                - **sign-out**: `{ accessToken }`
                - **forgot-password**: `{ email }`
                - **confirm-forgot-password**: `{ email, code, newPassword }`
      responses:
        "200":
          description: Authentication operation succeeded
          content:
            application/json:
              schema:
                type: object
                description: |
                  Response varies by action. On successful login/respond/refresh:
                  `{ accessToken, idToken, refreshToken? }`. On challenge:
                  `{ challenge, session, challengeParameters }`. On sign-out/forgot:
                  `{ success: true }`.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid credentials or expired session
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: "NOT_AUTHORIZED"
                  message:
                    type: string

  /tenants/{id}/api-keys:
    get:
      operationId: listApiKeys
      summary: List API keys for the tenant (metadata only)
      tags: [Onboarding]
      description: |
        Returns metadata for every API key owned by the tenant. The raw key is
        only available at creation time via `POST /tenants/{id}/api-keys`; this
        endpoint never returns the raw key or its hash. The tenant is taken
        from the authenticated caller — the path parameter must match, or the
        request is rejected with 403. Requires Cognito JWT.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: List of API keys
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required: [key_id, name, environment, created_at, status, last_four]
                      properties:
                        key_id:
                          type: string
                          example: "key_9c6d2e4f3a1b0d7e"
                        name:
                          type: string
                          example: "production-key"
                        environment:
                          type: string
                          enum: [live, test]
                          example: "live"
                        created_at:
                          type: string
                          format: date-time
                        status:
                          type: string
                          enum: [ACTIVE, REVOKED]
                          example: "ACTIVE"
                        last_four:
                          type: string
                          description: "Last four characters of the raw key — safe to display"
                          example: "1dce"
                        scopes:
                          description: |
                            Scopes enforced for this key at request time. `null`
                            means the key predates scope enforcement and is
                            treated as unbounded; an empty array means the key
                            has no granted scopes (effectively inert).
                          nullable: true
                          type: array
                          items:
                            $ref: "#/components/schemas/ApiScope"
                          example: ["mandates:read", "mandates:write", "verify"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      operationId: createApiKey
      summary: Generate a new API key for the tenant
      tags: [Onboarding]
      description: |
        Generates a new API key for a tenant. The raw key is returned only once
        in the response and cannot be retrieved again. Requires Cognito JWT.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  example: "production-key"
                environment:
                  type: string
                  enum: [live, test]
                  default: "live"
                scopes:
                  description: |
                    Scope list the issued key will be allowed to exercise.
                    Omitted or null means "all canonical scopes" — equivalent
                    to passing `["mandates:read","mandates:write","verify"]`.
                    An empty array issues an inert key. Unknown scopes are
                    rejected with 400.
                  type: array
                  items:
                    $ref: "#/components/schemas/ApiScope"
                  example: ["mandates:read", "verify"]
      responses:
        "201":
          description: API key created
          content:
            application/json:
              schema:
                type: object
                properties:
                  key_id:
                    type: string
                  api_key:
                    type: string
                    description: "Only shown once"
                  name:
                    type: string
                  environment:
                    type: string
                    enum: [live, test]
                  scopes:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiScope"
                  created_at:
                    type: string
                    format: date-time

        "401":
          $ref: "#/components/responses/Unauthorized"

  /tenants/{id}/api-keys/{keyId}:
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key for the tenant
      tags: [Onboarding]
      description: |
        Flips the per-key row to `status: "REVOKED"`. The authorizer reads
        per-key rows directly, so the key stops authenticating on the next
        request — other ACTIVE keys for the same tenant keep working.
        Idempotent for already-revoked keys. The tenant path parameter must
        match the authenticated caller. Requires Cognito JWT.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: keyId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Key revoked (no content)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /tenants/{id}/roles:
    get:
      operationId: listTenantRoleAssignments
      summary: List tenant role assignments
      tags: [Identity]
      description: |
        Returns the persisted role-assignment policy rows for a tenant. Each
        assignment binds a canonical subject ID to tenant-scoped roles and
        derived effective permissions. The tenant path parameter must match the
        authenticated caller unless the caller is a platform administrator.
        Requires `tenant:read` permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Tenant role assignments returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantRoleAssignmentList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/roles/{subjectId}:
    put:
      operationId: upsertTenantRoleAssignment
      summary: Create or update a tenant role assignment
      tags: [Identity]
      description: |
        Creates or replaces the tenant-scoped role assignment for the canonical
        subject ID in the path. `platform_admin` and `platform:*` grants are
        rejected because tenant policy can only delegate tenant-scoped access.
        Role-derived permissions and any explicit tenant permissions are stored
        as the assignment's effective permission set. Emits an immutable
        `TENANT_ROLE_ASSIGNED` audit event. Requires `tenant:write` permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: subjectId
          in: path
          required: true
          description: URL-encoded canonical subject ID resolved by the authorizer.
          schema:
            type: string
            minLength: 3
            maxLength: 256
            pattern: "^[A-Za-z0-9._:@/-]{3,256}$"
            example: "eudi:DE/1234567890abcdef"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantRoleAssignmentRequest"
      responses:
        "200":
          description: Tenant role assignment created or updated
          content:
            application/json:
              schema:
                type: object
                required: [tenant_id, assignment]
                properties:
                  tenant_id:
                    type: string
                  assignment:
                    $ref: "#/components/schemas/TenantRoleAssignment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    delete:
      operationId: revokeTenantRoleAssignment
      summary: Revoke a tenant role assignment
      tags: [Identity]
      description: |
        Revokes the tenant-scoped role assignment for the canonical subject ID
        by deleting the persisted policy row. The next authorizer evaluation no
        longer enriches the subject with those tenant roles or permissions.
        Emits an immutable `TENANT_ROLE_REVOKED` audit event. The operation is
        idempotent. Requires `tenant:write` permission.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: subjectId
          in: path
          required: true
          description: URL-encoded canonical subject ID resolved by the authorizer.
          schema:
            type: string
            minLength: 3
            maxLength: 256
            pattern: "^[A-Za-z0-9._:@/-]{3,256}$"
            example: "oidc:okta:00u123456789"
      responses:
        "204":
          description: Tenant role assignment revoked (no content)
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenant/workspaces/me:
    get:
      operationId: getCurrentWorkspaceEntitlements
      summary: Get current user's workspace entitlements
      tags: [Identity]
      description: |
        Returns the authenticated subject's workspace access for the current
        tenant context. If no explicit entitlement row exists, the response is
        derived from the tenant RBAC context: the Product Workspace is enabled
        by default, while the Partner & Project Workspace is enabled by default
        only for tenant administrators and platform administrators. Requires
        Cognito JWT or another authorizer-supported tenant principal.
      responses:
        "200":
          description: Current user's workspace entitlements returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkspaceEntitlement"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenant/users/{subjectId}/workspaces:
    get:
      operationId: getTenantUserWorkspaceEntitlements
      summary: Get workspace entitlements for a tenant user
      tags: [Identity]
      description: |
        Returns workspace access for the URL-encoded canonical subject ID in the
        current tenant. Reading another subject's entitlement requires
        `tenant:read` permission. If no explicit entitlement exists, default
        workspace access is resolved using the caller's tenant RBAC context.
      parameters:
        - name: subjectId
          in: path
          required: true
          description: URL-encoded canonical subject ID resolved by the authorizer.
          schema:
            type: string
            minLength: 3
            maxLength: 256
            pattern: "^[A-Za-z0-9._:@/-]{3,256}$"
            example: "eudi:DE/1234567890abcdef"
      responses:
        "200":
          description: Tenant user's workspace entitlements returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkspaceEntitlement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      operationId: updateTenantUserWorkspaceEntitlements
      summary: Update workspace entitlements for a tenant user
      tags: [Identity]
      description: |
        Creates or replaces the explicit workspace entitlement row for the
        URL-encoded canonical subject ID in the current tenant. Setting both
        workspace flags to `false` stores the entitlement as `REVOKED` and causes
        both workspaces to be disabled in the response. Emits an immutable
        `WORKSPACE_ENTITLEMENT_UPDATED` audit event. Requires `tenant:write`
        permission.
      parameters:
        - name: subjectId
          in: path
          required: true
          description: URL-encoded canonical subject ID resolved by the authorizer.
          schema:
            type: string
            minLength: 3
            maxLength: 256
            pattern: "^[A-Za-z0-9._:@/-]{3,256}$"
            example: "oidc:okta:00u123456789"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceEntitlementRequest"
      responses:
        "200":
          description: Tenant user's workspace entitlements updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkspaceEntitlement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/users/{subjectId}/workspaces:
    get:
      operationId: getTenantUserWorkspaceEntitlementsForTenant
      summary: Get workspace entitlements for a tenant user by tenant ID
      tags: [Identity]
      description: |
        Returns workspace access for the URL-encoded canonical subject ID within
        the tenant identified by the path parameter. Tenant-scoped callers may
        only use their own tenant ID unless the authorizer grants platform-admin
        privileges. Reading another subject's entitlement requires `tenant:read`
        permission. If no explicit entitlement exists, default workspace access
        is resolved using the caller's tenant RBAC context.
      parameters:
        - name: id
          in: path
          required: true
          description: Tenant ID whose user workspace entitlements should be read.
          schema:
            type: string
            minLength: 3
            maxLength: 128
            pattern: "^[A-Za-z0-9._:-]{3,128}$"
            example: "tnt_mandaitor_053cb127"
        - name: subjectId
          in: path
          required: true
          description: URL-encoded canonical subject ID or email address recorded in tenant access assignments.
          schema:
            type: string
            minLength: 3
            maxLength: 256
            pattern: "^[A-Za-z0-9._:@/-]{3,256}$"
            example: "oliver@mandaitor.io"
      responses:
        "200":
          description: Tenant user's workspace entitlements returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkspaceEntitlement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      operationId: updateTenantUserWorkspaceEntitlementsForTenant
      summary: Update workspace entitlements for a tenant user by tenant ID
      tags: [Identity]
      description: |
        Creates or replaces the explicit workspace entitlement row for the
        URL-encoded canonical subject ID within the tenant identified by the path
        parameter. Tenant-scoped callers may only use their own tenant ID unless
        the authorizer grants platform-admin privileges. Setting both workspace
        flags to `false` stores the entitlement as `REVOKED` and causes both
        workspaces to be disabled in the response. Emits an immutable
        `WORKSPACE_ENTITLEMENT_UPDATED` audit event. Requires `tenant:write`
        permission.
      parameters:
        - name: id
          in: path
          required: true
          description: Tenant ID whose user workspace entitlements should be updated.
          schema:
            type: string
            minLength: 3
            maxLength: 128
            pattern: "^[A-Za-z0-9._:-]{3,128}$"
            example: "tnt_mandaitor_053cb127"
        - name: subjectId
          in: path
          required: true
          description: URL-encoded canonical subject ID or email address recorded in tenant access assignments.
          schema:
            type: string
            minLength: 3
            maxLength: 256
            pattern: "^[A-Za-z0-9._:@/-]{3,256}$"
            example: "oliver@mandaitor.io"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceEntitlementRequest"
      responses:
        "200":
          description: Tenant user's workspace entitlements updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkspaceEntitlement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/events:
    get:
      operationId: listTenantEvents
      summary: List audit events for the tenant (all mandates)
      tags: [Events]
      description: |
        Tenant-scoped audit feed used by the dashboard Overview / Recent
        Activity. Returns events in descending timestamp order across all
        of the tenant's mandates. For a per-mandate timeline use
        `GET /events?mandate_id=…` instead. The tenant path parameter
        must match the authenticated caller. Requires Cognito JWT.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque base64url cursor from a previous response's `next_cursor`.
        - name: event_type
          in: query
          required: false
          schema:
            type: string
          description: Optional filter on event_type (applied after the tenant-scoped query).
      responses:
        "200":
          description: List of audit events
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        event_id:
                          type: string
                        event_type:
                          type: string
                        mandate_id:
                          type: string
                        tenant_id:
                          type: string
                        timestamp:
                          type: string
                          format: date-time
                  next_cursor:
                    type: string
                    nullable: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/audit-export:
    get:
      operationId: exportTenantAuditEvents
      summary: Export tenant audit events for SIEM ingestion
      tags: [Events]
      description: |
        Exports a tenant-scoped audit event slice in descending timestamp order
        for SIEM, GRC, and offline audit tooling. The tenant path parameter must
        match the authenticated caller unless the caller is a platform admin.
        Requires the `events:export` permission, which is granted to tenant
        administrators and tenant auditors.

        The `format` parameter selects the interchange representation:

        - `json` (default) — Mandaitor's native envelope with pagination
          metadata in the body.
        - `xes` — eXtensible Event Stream (IEEE 1849) for process mining
          (ProM, Celonis, Apromore, Signavio). One trace per mandate, ordered
          oldest-first, with the standard `concept`, `time`, `org`, `lifecycle`
          and `identity` extensions declared.
        - `cloudevents` — CNCF CloudEvents 1.0 JSON batch for event meshes
          (Knative, Event Grid, Kafka). Attribute names follow the
          specification's lowercase-alphanumeric constraint, so Mandaitor's
          snake_case fields appear as `tenantid`, `mandateid` and `eventhash`.

        XES and CloudEvents have no envelope for pagination, so the cursor,
        export id and event count are returned as `X-Mandaitor-*` response
        headers instead. These headers are listed in
        `Access-Control-Expose-Headers` so browser clients can read them.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [json, xes, cloudevents]
            default: json
          description: |
            Interchange format for the export. Case-insensitive. An unsupported
            value returns 400 rather than falling back to JSON, so a caller
            cannot mistake a downgraded payload for a successful export.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
          description: Maximum number of audit events to include in this export page.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque base64url cursor from a previous response's `next_cursor`.
        - name: event_type
          in: query
          required: false
          schema:
            type: string
          description: Optional filter on event_type for SIEM-specific routing or replay windows.
        - name: from_timestamp
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound for the audit-event timestamp sort key.
        - name: to_timestamp
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive upper bound for the audit-event timestamp sort key.
      responses:
        "200":
          description: |
            Tenant audit export payload. The media type follows the requested
            `format`.
          headers:
            Content-Disposition:
              description: Attachment filename, with the extension matching the format.
              schema:
                type: string
            X-Mandaitor-Next-Cursor:
              description: |
                Pagination cursor for the next page, present only when more
                events remain. Set for the `xes` and `cloudevents` formats,
                which carry no envelope; the `json` format returns the cursor
                as `next_cursor` in the body instead.
              schema:
                type: string
            X-Mandaitor-Export-Id:
              description: Export identifier, mirrored from the audit trail entry.
              schema:
                type: string
            X-Mandaitor-Event-Count:
              description: Number of events contained in this page.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantAuditExportResponse"
            application/xml:
              schema:
                type: string
                description: |
                  XES event log (IEEE 1849). One `<trace>` per mandate, events
                  ordered oldest-first.
              example: |
                <?xml version="1.0" encoding="UTF-8"?>
                <log xes.version="1849.2016" xes.features="nested-attributes">
                  <extension name="Concept" prefix="concept" uri="http://www.xes-standard.org/concept.xesext"/>
                  <trace>
                    <string key="concept:name" value="mdt_a1b2c3"/>
                    <event>
                      <string key="concept:name" value="MANDATE_CREATED"/>
                      <date key="time:timestamp" value="2026-04-20T10:00:00.000Z"/>
                      <string key="lifecycle:transition" value="complete"/>
                      <string key="org:resource" value="user:alice"/>
                    </event>
                  </trace>
                </log>
            application/cloudevents-batch+json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AuditEventCloudEvent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  # ─── Widget Configuration ───────────────────────────────────

  /tenants/{id}/widget-config:
    get:
      operationId: getWidgetConfig
      summary: Get tenant widget configuration
      tags: [Widget Config]
      description: |
        Retrieves the widget configuration for a tenant. Returns the latest
        version by default, or a specific version if the version query parameter is provided.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Widget configuration
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WidgetConfig"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    put:
      operationId: updateWidgetConfig
      summary: Update tenant widget configuration
      tags: [Widget Config]
      description: |
        Updates the widget configuration for a tenant. Creates a new versioned
        record. IdP credentials are stored in AWS Secrets Manager.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WidgetConfigUpdate"
      responses:
        "200":
          description: Configuration updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WidgetConfig"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /tenants/{id}/widget-config/validate-idp:
    post:
      operationId: validateIdpConnection
      summary: Validate an IdP connection
      tags: [Widget Config]
      description: |
        Validates an Identity Provider configuration by attempting a client
        credentials flow against the specified provider (Entra ID, Auth0, Okta, or eIDAS).

        **Alias support:** Accepts both `provider` (canonical) and `idpType` (legacy).
        If both are present, `provider` takes precedence.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  type: string
                  enum: [eidas_eudi, entra_id, auth0, okta]
                  description: "Canonical field. The IdP type to validate."
                idpType:
                  type: string
                  deprecated: true
                  description: "**Deprecated** legacy alias for `provider`."
                config:
                  type: object
              required: [config]
      responses:
        "200":
          description: Validation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        valid:
                          type: boolean
                        message:
                          type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /tenants/{id}/widget-config/versions:
    get:
      operationId: listConfigVersions
      summary: List widget configuration versions
      tags: [Widget Config]
      description: |
        Returns a paginated list of all configuration versions for the tenant,
        sorted by version number descending (newest first). Each entry contains
        version metadata (not the full configuration body).
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        "200":
          description: List of configuration versions
          content:
            application/json:
              schema:
                type: object
                properties:
                  versions:
                    type: array
                    items:
                      type: object
                      properties:
                        configVersion:
                          type: integer
                        updatedAt:
                          type: string
                          format: date-time
                        createdAt:
                          type: string
                          format: date-time
                        status:
                          type: string
                        widgetId:
                          type: string
                        rollbackFrom:
                          type: integer
                          description: Present if this version was created by a rollback
                  next_cursor:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /tenants/{id}/widget-config/rollback:
    post:
      operationId: rollbackConfig
      summary: Rollback widget configuration to a previous version
      tags: [Widget Config]
      description: |
        Rolls back the widget configuration to a previous version by creating a
        new version that is a copy of the target version. This preserves the
        full version history for audit purposes — no versions are deleted.

        The new version includes a `rollbackFrom` field indicating which version
        it was copied from.
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version]
              properties:
                version:
                  type: integer
                  description: The version number to roll back to
                  minimum: 1
      responses:
        "200":
          description: Rollback successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  version:
                    type: integer
                    description: The new version number created by the rollback
                  rollbackFrom:
                    type: integer
                    description: The version number that was rolled back to
                  widgetId:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /widgets/{widgetId}/config:
    get:
      operationId: getPublicWidgetConfig
      summary: Public widget config for embed (no auth)
      tags: [Public]
      description: |
        Returns the public-safe widget configuration for embedding. No authentication
        required. Sensitive fields (secrets, internal IDs) are stripped from the response.
      security: []
      x-auth-exemption-reason: >-
        Public-safe widget configuration served to embedding pages; secrets and internal
        identifiers are stripped from the response.
      parameters:
        - name: widgetId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Public widget configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  widget_id:
                    type: string
                  tenant_name:
                    type: string
                  branding:
                    type: object
                  enabled_idps:
                    type: array
                    items:
                      type: string
                  taxonomy_libraries:
                    type: array
                    items:
                      type: string
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Billing & Usage ───────────────────────────────────────

  /tenants/{id}/usage:
    get:
      operationId: getTenantUsage
      summary: Get usage data for a tenant
      tags: [Billing]
      description: |
        Returns usage data for the authenticated tenant's current or specified
        billing period. Includes total units, per-endpoint breakdown, daily
        usage timeline, and per-API-key breakdown. The tenant path parameter
        must match the authenticated caller.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: period
          in: query
          required: false
          schema:
            type: string
            pattern: "^\\d{4}-\\d{2}$"
            example: "2026-04"
          description: Billing period in YYYY-MM format. Defaults to current month.
        - name: granularity
          in: query
          required: false
          schema:
            type: string
            enum: [daily, hourly]
            default: daily
      responses:
        "200":
          description: Usage data for the billing period
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id:
                    type: string
                  billing_period:
                    type: string
                    example: "2026-04"
                  total_units:
                    type: number
                    format: double
                  endpoint_breakdown:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Map of endpoint name to unit count"
                  daily_usage:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date
                        units:
                          type: number
                          format: double
                  api_key_breakdown:
                    type: array
                    items:
                      type: object
                      properties:
                        api_key_id:
                          type: string
                        total_units:
                          type: number
                          format: double
                  granularity:
                    type: string
                    enum: [daily, hourly]
                  allowance:
                    type: object
                    description: |
                      The monthly volume ceiling for this tenant. Free-tier
                      tenants may spend a fixed number of billable units per
                      calendar month; past that, their API keys are refused
                      until the month rolls over, while dashboard sign-in keeps
                      working. Tenants on a paid plan are not capped and report
                      `metered: false`.
                    properties:
                      metered:
                        type: boolean
                        description: Whether a ceiling applies to this tenant at all.
                      period:
                        type: string
                        description: Billing period the figures cover, `YYYY-MM`.
                      limit:
                        type: integer
                        nullable: true
                        description: Units allowed in the period, or null when not metered.
                      used:
                        type: integer
                        description: Units consumed in the period.
                      remaining:
                        type: integer
                        nullable: true
                        description: Units left before keys are refused, or null when not metered.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/billing:
    get:
      operationId: getTenantBilling
      summary: Get billing status and invoice history
      tags: [Billing]
      description: |
        Returns the side-effect-free local billing projection for the
        authenticated tenant: the single approved metered offer, payment and
        entitlement state, recent invoices, and current-period usage. This GET
        never creates a Stripe Customer Portal session. The tenant path
        parameter must match the authenticated caller.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Billing status and invoice history
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id:
                    type: string
                  catalog_version:
                    type: string
                  offer:
                    type: object
                    required: [id, name, currency, unit_amount_minor, billing_block_units]
                    properties:
                      id:
                        type: string
                        enum: [metered]
                      name:
                        type: string
                      currency:
                        type: string
                        enum: [eur]
                      unit_amount_minor:
                        type: integer
                        enum: [10]
                      billing_block_units:
                        type: integer
                        enum: [1000]
                  billing_status:
                    type: string
                    description: Local lifecycle status; only CURRENT grants paid entitlement.
                  billing_plan:
                    type: string
                    enum: [metered]
                  billing_plan_name:
                    type: string
                  entitlement_status:
                    type: string
                    enum: [active, pending, inactive]
                  activation_authority:
                    type: string
                  has_payment_method:
                    type: boolean
                  stripe_connected:
                    type: boolean
                  portal_available:
                    type: boolean
                  subscription:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: string
                      status:
                        type: string
                      current_period_start:
                        type: string
                        format: date-time
                        nullable: true
                      current_period_end:
                        type: string
                        format: date-time
                        nullable: true
                      cancel_at_period_end:
                        type: boolean
                  invoices:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        number:
                          type: string
                          nullable: true
                        billing_period:
                          type: string
                        status:
                          type: string
                        amount_due:
                          type: integer
                        amount_paid:
                          type: integer
                        currency:
                          type: string
                        created_at:
                          type: string
                          format: date-time
                          nullable: true
                        paid_at:
                          type: string
                          format: date-time
                          nullable: true
                        hosted_invoice_url:
                          type: string
                          format: uri
                          nullable: true
                        pdf_url:
                          type: string
                          format: uri
                          nullable: true
                  current_period:
                    type: object
                    nullable: true
                    properties:
                      billing_period:
                        type: string
                      total_units:
                        type: number
                        format: double
                        description: Weighted usage units; fractional values such as 1.5 are valid
                      endpoint_breakdown:
                        type: object
                        additionalProperties:
                          type: number
                      estimated_amount_minor:
                        type: integer
                      currency:
                        type: string
                        enum: [eur]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /tenants/{id}/billing/setup-intent:
    post:
      operationId: createBillingSetupIntent
      summary: Create a Stripe SetupIntent for card collection
      tags: [Billing]
      description: |
        Starts product-owned payment-method collection for the tenant when the
        rollout flags are enabled. A successful SetupIntent never activates
        paid access. Returns a client secret for Stripe.js / Stripe Elements.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: SetupIntent created
          content:
            application/json:
              schema:
                type: object
                required: [client_secret, status]
                properties:
                  client_secret:
                    type: string
                    description: Stripe SetupIntent client secret for frontend use
                  status:
                    type: string
                    enum: [pending]
                  message:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          description: Stripe API error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string

  /tenants/{id}/billing/subscription:
    post:
      operationId: createBillingSubscription
      summary: Start the single Mandaitor metered billing offer
      tags: [Billing]
      description: |
        Starts or resumes the single EUR 0.10 per 1,000 unit Mandaitor offer
        when rollout flags are enabled. The handler records a pending attempt;
        it never grants paid access. Activation is projected only after an
        eligible owned subscription and a paid or legitimately zero initial
        invoice. If payment details are missing, a SetupIntent client secret is
        returned without activating service.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan:
                  type: string
                  enum: [metered]
                  description: The only approved Mandaitor paid offer
      responses:
        "200":
          description: Subscription created or updated
          content:
            application/json:
              schema:
                type: object
                required: [plan, plan_name, status, requires_payment]
                properties:
                  plan:
                    type: string
                    enum: [metered]
                  plan_name:
                    type: string
                  status:
                    type: string
                    enum: [active, pending]
                  requires_payment:
                    type: boolean
                    description: Whether the frontend needs to collect a payment method
                  requires_action:
                    type: boolean
                    description: Whether the initial invoice payment requires customer authentication
                  subscription_id:
                    type: string
                    description: Stripe Subscription ID when the pending subscription exists
                  client_secret:
                    type: string
                    description: Stripe SetupIntent client secret (only if requires_payment is true)
                  payment_client_secret:
                    type: string
                    description: Stripe PaymentIntent client secret (only if requires_action is true)
                  message:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          description: Stripe API error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string

  /tenants/{id}/playground/verify:
    post:
      operationId: playgroundVerify
      summary: Playground dry-run verify (Cognito JWT, no raw API key)
      tags: [Verification]
      description: |
        Dashboard-only dry-run verify endpoint. Authenticated via Cognito JWT
        (dashboard session). The caller selects an API key by its key_id from
        a dropdown; the handler loads the key's metadata (scopes, status) and
        simulates the verify flow as if the request had come through that key.
        No raw API key is ever needed. Metering is intentionally skipped.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key_id, action, resource]
              properties:
                key_id:
                  type: string
                  description: API key ID to simulate (from the dropdown)
                action:
                  type: string
                  description: The action being attempted
                resource:
                  type: string
                  description: The resource being accessed
                delegate_subject_id:
                  type: string
                  description: The delegate attempting the action
                agent_id:
                  type: string
                  description: Alternative to delegate_subject_id
                context:
                  type: object
                  additionalProperties: true
                  description: Additional context for constraint evaluation
                request_pom:
                  type: boolean
                  description: Whether to include a Proof-of-Mandate VC
      responses:
        "200":
          description: Playground verification result
          content:
            application/json:
              schema:
                type: object
                required: [decision, event_id, _playground]
                properties:
                  decision:
                    type: string
                    enum: [ALLOW, DENY]
                  mandate_id:
                    type: string
                  event_id:
                    type: string
                  reason_codes:
                    type: array
                    items:
                      type: string
                  constraints_remaining:
                    type: object
                  proof_of_mandate:
                    type: object
                  proof_token:
                    type: string
                  _playground:
                    type: object
                    properties:
                      simulated_key:
                        type: object
                        properties:
                          key_id:
                            type: string
                          key_name:
                            type: string
                          last_four:
                            type: string
                          scopes:
                            type: array
                            items:
                              type: string
                            nullable: true
                          has_verify_scope:
                            type: boolean
                      dry_run:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          description: Internal server error

  /tenants/{id}/onboarding-status:
    get:
      operationId: getOnboardingStatus
      summary: Get dashboard onboarding status
      tags: [Tenants]
      description: |
        Returns the current dashboard onboarding tour status for the
        authenticated tenant. Used by the guided tour overlay to determine
        whether to show the onboarding flow.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: workspace
          in: query
          required: false
          description: Workspace onboarding context. Defaults to product_workspace for backward compatibility.
          schema:
            type: string
            enum: [product_workspace, partner_project_workspace]
      responses:
        "200":
          description: Current onboarding status
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id:
                    type: string
                  onboarding:
                    type: object
                    properties:
                      completed:
                        type: boolean
                      skipped:
                        type: boolean
                      completed_steps:
                        type: array
                        items:
                          type: string
                      current_step:
                        type: string
                      completed_at:
                        type: string
                        nullable: true
                      skipped_at:
                        type: string
                        nullable: true
                      started_at:
                        type: string
                        nullable: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateOnboardingStatus
      summary: Update dashboard onboarding progress
      tags: [Tenants]
      description: |
        Updates the dashboard onboarding tour progress for the authenticated
        tenant. Supports marking steps as completed, advancing the current
        step, and completing or skipping the entire tour.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: workspace
          in: query
          required: false
          description: Workspace onboarding context. Defaults to product_workspace for backward compatibility.
          schema:
            type: string
            enum: [product_workspace, partner_project_workspace]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                completed:
                  type: boolean
                skipped:
                  type: boolean
                completed_steps:
                  type: array
                  items:
                    type: string
                current_step:
                  type: string
      responses:
        "200":
          description: Updated onboarding status
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id:
                    type: string
                  onboarding:
                    type: object
                    properties:
                      completed:
                        type: boolean
                      skipped:
                        type: boolean
                      completed_steps:
                        type: array
                        items:
                          type: string
                      current_step:
                        type: string
                      completed_at:
                        type: string
                        nullable: true
                      skipped_at:
                        type: string
                        nullable: true
                      started_at:
                        type: string
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhooks/stripe:
    post:
      operationId: handleStripeWebhook
      summary: Stripe webhook endpoint
      tags: [Billing]
      security: []
      x-auth-exemption-reason: >-
        Authenticated by Stripe webhook signature verification rather than by a platform
        credential; the global API-key and bearer schemes do not apply to a provider callback.
      description: |
        Receives and processes Stripe webhook events. Authentication is
        performed via Stripe signature verification (not API key or JWT).
        Handles invoice lifecycle events (payment succeeded/failed/finalized)
        and subscription changes (updated/deleted).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw Stripe webhook event payload
      responses:
        "200":
          description: Webhook received and processed
          content:
            application/json:
              schema:
                type: object
                properties:
                  received:
                    type: boolean
        "400":
          description: Invalid signature or malformed payload
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string

  /admin/billing/sync-customers:
    post:
      operationId: syncStripeCustomers
      summary: Reconcile product-owned Stripe Customers
      tags: [Admin, Billing]
      description: |
        Admin-only, dry-run-first Customer inventory. Every mapped Customer is
        retrieved read-only and checked against the exact Mandaitor product,
        stage, and opaque owner metadata. The default request never writes.
        `{ "apply": true }` creates Customers only for unmapped tenants and is
        rejected unless `BILLING_CUSTOMER_SYNC_APPLY_ENABLED=true`. It never
        creates subscriptions and is not scheduled automatically.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                apply:
                  type: boolean
                  default: false
                  description: Request gated Customer creation for unmapped tenants
      responses:
        "200":
          description: Sync result
          content:
            application/json:
              schema:
                type: object
                required:
                  [
                    mode,
                    scanned,
                    verified_owned,
                    legacy_mapping_requires_review,
                    missing_customer,
                    deleted_customer,
                    metadata_mismatch,
                    remote_lookup_failed,
                    created,
                    failed,
                  ]
                properties:
                  mode:
                    type: string
                    enum: [dry_run, apply]
                  scanned:
                    type: integer
                  verified_owned:
                    type: integer
                  legacy_mapping_requires_review:
                    type: integer
                  missing_customer:
                    type: integer
                  deleted_customer:
                    type: integer
                  metadata_mismatch:
                    type: integer
                  remote_lookup_failed:
                    type: integer
                  created:
                    type: integer
                  failed:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  # ─── Drift Monitor (experimental) ──────────────────────────────

  /drift/agents:
    get:
      operationId: listDriftAgents
      summary: List agents with drift summaries
      tags: [Drift Monitor]
      description: |
        **@experimental** Aggregates drift session data across all mandates
        to produce per-agent summaries including average/max drift scores,
        session counts, and last activity timestamps.
      responses:
        "200":
          description: Array of agent drift summaries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AgentDriftSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /drift/events:
    get:
      operationId: listDriftEvents
      summary: List recent drift detection events
      tags: [Drift Monitor]
      description: |
        **@experimental** Queries the tenant-wide event timeline for
        DRIFT_DETECTED and DRIFT_RESOLVED events, enriched with the drift
        score from the event details. Results are newest-first and paginate
        via an opaque cursor; follow `next_cursor` until it is absent to
        walk the full drift history.
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          description: Maximum number of events to return per page.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque base64url cursor from a previous response's `next_cursor`.
      responses:
        "200":
          description: One page of drift events
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/DriftEvent"
                  next_cursor:
                    type: string
                    description: |
                      Present when more events may exist. A page can be
                      shorter than `limit` (or even empty) while still
                      carrying a `next_cursor` — drift events are filtered
                      out of a larger timeline, and the server bounds how
                      much it scans per request. Only an absent
                      `next_cursor` means the history is exhausted.
        "400":
          description: Missing tenant context or invalid pagination cursor
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /drift/agents/{delegateId}/sessions:
    get:
      operationId: listDriftAgentSessions
      summary: List drift sessions for a specific agent
      tags: [Drift Monitor]
      description: |
        **@experimental** Queries the drift sessions table to retrieve all
        recent session windows for a given delegate, enriched with computed
        drift scores and mandate context.
      parameters:
        - name: delegateId
          in: path
          required: true
          schema:
            type: string
          description: The delegate subject ID.
        - name: hours
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 168
            default: 24
          description: Lookback window in hours.
      responses:
        "200":
          description: Array of drift sessions for the agent
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/DriftSessionResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: Missing delegate ID or tenant context
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ─── Horizon 1: VI Credential ──────────────────────────────

  /mandates/{id}/vi-credential:
    post:
      operationId: issueViCredential
      summary: Issue a Verifiable Intent credential chain
      tags: [VI Credential]
      description: |
        Generates a VI-spec compatible credential chain (L1 + L2) from an
        existing active mandate. The chain can be presented to external
        verifiers that implement the Verifiable Intent specification.
      parameters:
        - $ref: "#/components/parameters/MandateId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ViCredentialRequest"
      responses:
        "200":
          description: VI credential chain issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ViCredentialResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Mandate not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ─── Horizon 1: Agent Registry ─────────────────────────────

  /agents:
    post:
      operationId: registerAgent
      summary: Register a new AI agent identity
      tags: [Agent Registry]
      description: |
        Creates a new agent identity with a DID, KMS-backed key pair, and
        initial ACTIVE status. The agent can then be bound to mandates.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAgentRequest"
      responses:
        "201":
          description: Agent registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    get:
      operationId: listAgents
      summary: List registered agents
      tags: [Agent Registry]
      description: |
        Returns a paginated list of agent identities for the authenticated tenant.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [ACTIVE, SUSPENDED, REVOKED]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Paginated list of agents
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/AgentIdentity"
                  cursor:
                    type: string
                    nullable: true
        "401":
          $ref: "#/components/responses/Unauthorized"

  /agents/{id}:
    get:
      operationId: getAgent
      summary: Get agent details
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Agent details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Agent not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agents/{id}/did:
    get:
      operationId: getAgentDid
      summary: Resolve agent DID Document
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: DID Document
          content:
            application/did+json:
              schema:
                type: object
        "404":
          description: Agent not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agents/{id}/rotate:
    post:
      operationId: rotateAgentKey
      summary: Rotate agent cryptographic key
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                algorithm:
                  type: string
                  enum: [ES256, ES384, EdDSA]
                  default: ES256
                public_key_jwk:
                  $ref: "#/components/schemas/ClientPublicKeyJwk"
      responses:
        "200":
          description: Key rotated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "404":
          description: Agent not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agents/{id}/bind:
    post:
      operationId: bindAgentToMandate
      summary: Bind agent to a mandate
      description: |
        Lets the agent present a mandate in its MCP sessions. The mandate must be
        delegated to this agent: its `delegate.subject_id` is the agent's DID or
        agent id. A binding grants no authority of its own: `/mcp/token` issues
        a session only to an agent bound to a mandate that names it, and an
        execution is decided on the agent's active mandates, bound or not.
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mandate_id]
              properties:
                mandate_id:
                  type: string
      responses:
        "200":
          description: Agent bound to mandate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "404":
          description: Agent or mandate not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The mandate is not delegated to this agent (`code` is `MANDATE_DELEGATE_MISMATCH`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agents/{id}/bind/{mandateId}:
    delete:
      operationId: unbindAgentFromMandate
      summary: Unbind agent from a mandate
      description: |
        MCP sessions the agent already holds stop using the mandate at once.
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: mandateId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Agent unbound from mandate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "404":
          description: Agent not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agents/{id}/suspend:
    post:
      operationId: suspendAgent
      summary: Suspend an agent
      description: |
        Ends every MCP session the agent holds, at once. The agent cannot obtain a
        token while suspended, and its earlier sessions stay dead after it is
        reactivated.
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Agent suspended
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "404":
          description: Agent not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agents/{id}/reactivate:
    post:
      operationId: reactivateAgent
      summary: Reactivate a suspended agent
      description: |
        The agent may request a new token from `/mcp/token`. Sessions issued
        before the suspension stay dead.
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Agent reactivated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "404":
          description: Agent not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /agents/{id}/revoke:
    post:
      operationId: revokeAgent
      summary: Permanently revoke an agent
      description: |
        Revokes the agent's keys and ends every MCP session it holds. The agent
        can never obtain a token again.
      tags: [Agent Registry]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Agent revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentIdentity"
        "404":
          description: Agent not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ─── Horizon 1: MCP Authorization Server ───────────────────

  /.well-known/oauth-authorization-server:
    get:
      operationId: getMcpServerMetadata
      summary: MCP OAuth Authorization Server Metadata
      tags: [MCP Authorization]
      description: |
        The OAuth 2.0 Authorization Server Metadata (RFC 8414) of
        `POST /mcp/token`, where agents get their sessions. The same document
        is served at the domain's root, where RFC 8414 places it for this
        issuer and where MCP clients look for it:
        `https://api.mandaitor.io/.well-known/oauth-authorization-server/v1`.

        Agents authenticate with the client credentials grant and their
        registry key (`private_key_jwt`). No browser flow exists yet:
        `response_types_supported` is empty, and the `authorization_endpoint`
        it names answers so.
      security: []
      x-auth-exemption-reason: >-
        OAuth 2.0 Authorization Server Metadata. RFC 8414 requires it to be publicly retrievable
        so MCP clients can discover the token and authorization endpoints.
      responses:
        "200":
          description: Server metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MCPServerMetadata"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /mcp/token:
    post:
      operationId: mcpTokenExchange
      summary: Exchange agent credentials for MCP access token
      tags: [MCP Authorization]
      description: |
        OAuth 2.0 client_credentials grant for MCP agents. The agent
        presents a signed JWT assertion and receives a scoped access
        token bound to its mandates. The parameters may be sent
        form-encoded, as OAuth clients send them, or as a JSON object.

        The assertion (`private_key_jwt`) is signed with the agent's active
        registry key, which `kid` may name. It must carry `iss` and `sub` naming
        the agent (its DID), `aud` naming the authorization server — its
        issuer (`https://api.mandaitor.io/v1`) or this endpoint's URL — a
        unique `jti` of at most 256 characters, and an `exp` no more than
        five minutes ahead (a minute of clock skew is tolerated). Each
        assertion is accepted once: presenting the same `jti` again returns
        401, so sign a new assertion for every token request. `client_id`
        may be left out, since the assertion's `iss` names the agent
        (RFC 7521); when both are sent, they must agree.

        `resource` (RFC 8707) names what the token is for. The MCP server's
        URL (`https://api.mandaitor.io/v1/mcp`) gives a session that only
        the MCP server accepts. The API's base URL, or no `resource`, gives
        one that only the API's own routes accept. Anything else is 400
        `invalid_target`.

        **A session bound to the agent's key** (DPoP, RFC 9449). A `DPoP`
        header with a proof for this request, signed by a key the client
        holds, binds the session to that key: it is issued with
        `token_type` `DPoP` and lasts an hour, and every request with it must
        carry a fresh proof signed by the same key (`McpSessionDPoP`), so
        the token alone is of no use to anyone who takes it. `/executions`
        takes only such a session. Without a proof, the session is a bearer
        one (`token_type` `Bearer`) and lasts 15 minutes: the MCP server and
        `GET /mcp/sessions/{id}` take it, `/executions` does not.
      security: []
      x-auth-exemption-reason: >-
        OAuth 2.0 client_credentials token endpoint. The agent authenticates with a signed JWT
        assertion in the request body; this endpoint issues the access token, so it cannot
        require one.
      parameters:
        - name: DPoP
          in: header
          required: false
          description: |
            A DPoP proof (RFC 9449) for this request, asking for a session
            bound to its key: `htm` `POST`, `htu` this endpoint's URL, and no
            `ath`, since no token is presented yet. See `McpSessionDPoP` for
            what a proof is.
          schema:
            type: string
            maxLength: 4096
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/MCPTokenRequest"
          application/json:
            schema:
              $ref: "#/components/schemas/MCPTokenRequest"
      responses:
        "200":
          description: Access token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MCPTokenResponse"
        "400":
          description: |
            `invalid_request` (a missing or repeated parameter, or a body that is
            neither a form nor JSON), `unsupported_grant_type`, `invalid_target`
            (a `resource` other than the MCP server or the API), or
            `invalid_dpop_proof`: more than one `DPoP` header, or a proof that
            is not for this request, does not verify with its own key, is
            outside its window, or was used before.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "401":
          description: Invalid client credentials, including an assertion that has already been used
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /mcp:
    post:
      operationId: mcpMessage
      summary: Send one MCP message (Streamable HTTP)
      tags: [MCP Server]
      description: |
        The MCP server. The body is one JSON-RPC 2.0 message of the Model
        Context Protocol. The server speaks 2025-11-25, 2025-06-18 and
        2025-03-26, and answers every request with one JSON object, never
        a stream. A batch is refused, and a notification gets 202 and no body.

        - `initialize`: the negotiated version, `tools` as the only
          capability, and instructions for the model.
        - `ping`.
        - `tools/list`: the tools the tenant enabled that a mandate naming
          the agent could back now, then the server's own `get_execution`. A
          tool's name is its id with each dot an underscore
          (`sap_purchase_requisition_release`).
        - `tools/call`: one execution, requested as `POST /executions`
          requests it, from the tool's arguments and an optional `purpose`
          for the approver. The call waits up to about 20 seconds for the
          execution to settle and answers with its outcome. One still
          running, or waiting for a person's approval, is answered with its
          state and id, for `get_execution`. While 50 calls are already
          waiting, across every agent, a call answers at once instead. A repeat of the same call by the
          same agent names the same execution while it runs, and for 10
          minutes after it ends. A denial or a failure is a tool error
          (`isError: true`); an unknown or not enabled tool is a JSON-RPC
          error (-32602). The result's `structuredContent` is the execution
          as `GET /executions/{id}` returns it.

        Authenticated with a session from `POST /mcp/token` issued for this
        server (its `resource` this URL): a bearer session, as stock MCP
        clients hold, or one bound to the agent's key, sent as `DPoP` with a
        proof for each request. Without one, the 401's `WWW-Authenticate`
        names the protected resource metadata (RFC 9728):
        `https://api.mandaitor.io/.well-known/oauth-protected-resource/v1/mcp`.
        A browser's request from an origin this API does not serve is
        refused with 403.

        An agent may send 60 requests a minute, whatever they ask; past that,
        each is refused with 429 before anything is read. A tool call also
        counts against the agent's 30 executions a minute, and past those it is
        a tool error that says when to try again.
      security:
        - McpSessionAuth: []
        - McpSessionDPoP: []
      parameters:
        - $ref: "#/components/parameters/DPoPProofIfBound"
        - name: MCP-Protocol-Version
          in: header
          required: false
          description: The version negotiated at `initialize`, sent after it. Without it, 2025-03-26 is assumed.
          schema:
            type: string
            enum: ["2025-11-25", "2025-06-18", "2025-03-26"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/McpJsonRpcMessage"
      responses:
        "200":
          description: The answer to a request, a JSON-RPC result or error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/McpJsonRpcResponse"
        "202":
          description: A notification, or a response, accepted. No body.
        "400":
          description: Not one JSON-RPC 2.0 message, or an MCP-Protocol-Version the server does not speak
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/McpJsonRpcResponse"
        "401":
          description: |
            No session issued for this server, or a session bound to a key
            sent without a valid proof (`invalid_dpop_proof`) or as `Bearer`.
          headers:
            WWW-Authenticate:
              description: |
                `Bearer resource_metadata="…"`, with `error="invalid_token"` when a
                token was sent. For a session bound to a key,
                `DPoP algs="ES256 ES384", error="…", error_description="…", resource_metadata="…"`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/McpJsonRpcResponse"
        "403":
          description: The request's Origin is not one this API serves
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/McpJsonRpcResponse"
        "429":
          description: |
            More than 60 requests by this agent in the current minute: a
            JSON-RPC error with code -32029 and no id. Nothing was read. Retry
            after `Retry-After`, the seconds until the minute ends. The gateway
            also answers 429, with `{"message":"Too Many Requests"}`, when the
            requests of every agent together pass the server's throttle.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/McpJsonRpcResponse"
    get:
      operationId: mcpOpenStream
      summary: Open a server-sent event stream (not offered)
      tags: [MCP Server]
      description: Always 405. The server sends no messages of its own, so it offers no stream.
      security: []
      x-auth-exemption-reason: >-
        Answers 405 to every caller. Streamable HTTP lets a server that offers no event stream
        refuse GET this way, and there is nothing behind it to protect.
      responses:
        "405":
          description: No stream is offered
          headers:
            Allow:
              schema:
                type: string
                example: POST
    delete:
      operationId: mcpEndSession
      summary: End an MCP session (not offered)
      tags: [MCP Server]
      description: Always 405. The server keeps no MCP session, so there is none to end.
      security: []
      x-auth-exemption-reason: >-
        Answers 405 to every caller. The server issues no MCP session id, and Streamable HTTP lets
        it refuse DELETE this way.
      responses:
        "405":
          description: There is no session to end
          headers:
            Allow:
              schema:
                type: string
                example: POST

  /mcp/authorize:
    get:
      operationId: mcpAuthorizationEndpoint
      summary: OAuth authorization endpoint (no browser flow yet)
      tags: [MCP Authorization]
      description: |
        The authorization endpoint the server metadata names, because MCP
        clients refuse metadata without one. Mandaitor issues sessions only to
        registered agents, with the client credentials grant at
        `POST /mcp/token`. Signing in as a person through an MCP client is not
        available yet. Always 400 `unsupported_response_type`.
      security: []
      x-auth-exemption-reason: >-
        OAuth 2.0 authorization endpoint (RFC 6749 §3.1), which a browser reaches before any token
        exists. It grants nothing and answers every request with an error.
      responses:
        "400":
          description: No browser flow is offered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"

  /mcp/sessions/{id}:
    get:
      operationId: getMcpSession
      summary: Get MCP session details
      tags: [MCP Authorization]
      description: |
        Requires the session's own access token (`Authorization: Bearer mcp_…`,
        or `DPoP mcp_…` with a proof for a session bound to the agent's key);
        a token for a different session returns 404. So does a session whose
        agent has been suspended or revoked since it was issued.
      security:
        - McpSessionAuth: []
        - McpSessionDPoP: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/DPoPProofIfBound"
      responses:
        "200":
          description: Session details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MCPSession"
        "401":
          $ref: "#/components/responses/AgentSessionUnauthorized"
        "404":
          description: Session not found, expired, or ended by its agent's suspension or revocation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ═══════════════════════════════════════════════════════════
  # Horizon 2: Consent UX
  # ═══════════════════════════════════════════════════════════

  /consents:
    post:
      operationId: createConsentRequest
      summary: Create a consent request for human-in-the-loop approval
      tags: [Consent]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateConsentRequest"
      responses:
        "201":
          description: Consent request created (or auto-approved by policy)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentRequest"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /consents/pending:
    get:
      operationId: listPendingConsents
      summary: List pending consent requests for the authenticated user
      description: >-
        The pending consent requests of the caller's own tenant that name the
        caller as approver, in one page of up to `limit`. An execution's
        approval past its `expires_at` is left out: it can no longer be
        decided.
      tags: [Consent]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
      responses:
        "200":
          description: The pending consent requests naming the caller
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedConsentRequests"
        "400":
          description: Invalid query parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /consents/{id}:
    get:
      operationId: getConsentRequest
      summary: Get a consent request by ID
      tags: [Consent]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Consent request details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentRequest"
        "404":
          description: Consent request not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /consents/{id}/resolve:
    post:
      operationId: resolveConsentRequest
      summary: Approve or reject a consent request
      tags: [Consent]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResolveConsentRequest"
      responses:
        "200":
          description: Consent request resolved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentRequest"
        "404":
          description: Consent request not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Consent request already resolved; or `EXECUTION_APPROVAL`: the
            consent is an execution's approval, which only
            `POST /executions/{id}/approval` decides.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /mandates/{id}/consents:
    get:
      operationId: listMandateConsents
      summary: List consent requests for a specific mandate
      tags: [Consent]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, approved, rejected, expired]
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Paginated list of consent requests for the mandate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedConsentRequests"
        "404":
          description: Mandate not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /consent-policies:
    post:
      operationId: createConsentPolicy
      summary: Create an auto-approval policy
      tags: [Consent]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateConsentPolicy"
      responses:
        "201":
          description: Consent policy created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentPolicy"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      operationId: listConsentPolicies
      summary: List consent policies for the tenant
      tags: [Consent]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Paginated list of consent policies
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedConsentPolicies"
        "400":
          description: Invalid query parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /consent-policies/{id}:
    get:
      operationId: getConsentPolicy
      summary: Get a consent policy by ID
      tags: [Consent]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Consent policy details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentPolicy"
        "404":
          description: Consent policy not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      operationId: updateConsentPolicy
      summary: Update a consent policy
      tags: [Consent]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateConsentPolicy"
      responses:
        "200":
          description: Consent policy updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentPolicy"
        "404":
          description: Consent policy not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: deleteConsentPolicy
      summary: Delete a consent policy
      tags: [Consent]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Consent policy deleted
        "404":
          description: Consent policy not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ═══════════════════════════════════════════════════════════
  # Enterprise Connectors
  # ═══════════════════════════════════════════════════════════

  /connectors/catalog:
    get:
      operationId: getConnectorCatalog
      summary: List the connector catalog
      description: |
        Enterprise systems Mandaitor can govern. Each catalog entry lists the
        registered tools that act through the connector and the fields a
        tenant must configure. The mock adapters once listed with
        `configurable: false` retired in execution roadmap E2.11.
      tags: [Connectors]
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Connector catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  connectors:
                    type: array
                    items:
                      $ref: "#/components/schemas/ConnectorCatalogEntry"

  /tenants/{id}/connectors:
    get:
      operationId: listTenantConnectors
      summary: List configured connector instances
      tags: [Connectors]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Configured connector instances (secret fields omitted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  connectors:
                    type: array
                    items:
                      $ref: "#/components/schemas/TenantConnector"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      operationId: createTenantConnector
      summary: Configure a new connector instance
      description: |
        Validates the configuration against the catalog entry, rejects
        instance URLs the SSRF guard would block, KMS-encrypts secret fields,
        and stores the instance with status `unverified`. Secret fields are
        write-only — they are never returned by any connector route.
      tags: [Connectors]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTenantConnector"
      responses:
        "201":
          description: Connector instance created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantConnector"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/connectors/sandbox:
    post:
      operationId: provisionSandboxConnector
      summary: Provision a hosted demo sandbox and configure a connector for it
      description: |
        Creates a Mandaitor-hosted emulation of the requested enterprise system
        (ServiceNow Table API or SAP S/4HANA OData), seeds it with the demo
        records, and configures a connector instance pointing at it — so the
        full governed loop can be exercised without a customer system.

        The result is an ordinary connector: same storage, same KMS-encrypted
        credential, same test-connection probe, same governed tool calls. Only
        the target differs, and `sandbox_id` marks it as sandbox-backed.
        Sandboxes expire after 30 days and are deleted with their connector.
      tags: [Connectors]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [servicenow, sap-s4hana]
                  description: Which enterprise system to emulate
                name:
                  type: string
                  maxLength: 100
                  description: Optional display name; defaults to "<System> (demo sandbox)"
                environment:
                  allOf:
                    - $ref: "#/components/schemas/Environment"
                  description: The environment the sandbox stands in for, `default` when not given
      responses:
        "201":
          description: |
            Sandbox provisioned and connector configured.

            A sandbox's credential is never disclosed: only Mandaitor's
            executor writes to it, through the execution API
            (`POST /executions`). A ServiceNow sandbox provisioned before
            execution roadmap E2.11 showed its credential once; replacing the
            connector's credential
            (`PUT /tenants/{id}/connectors/{connectorId}/credentials`) retires
            the one shown.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantConnector"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/connectors/{connectorId}/reset:
    post:
      operationId: resetSandboxConnector
      summary: Reset a sandbox-backed connector's data to its seeded state
      description: |
        Restores the hosted sandbox behind this connector to the records it was
        provisioned with, so a demo can be re-run from a clean slate. Rejected
        for connectors that point at a real system.
      tags: [Connectors]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: connectorId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Sandbox data restored
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: reset
                  connector:
                    $ref: "#/components/schemas/TenantConnector"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

  /tenants/{id}/connectors/{connectorId}:
    delete:
      operationId: deleteTenantConnector
      summary: Remove a connector instance
      tags: [Connectors]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: connectorId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Connector instance removed
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            `CONNECTOR_IN_USE`: a tool is enabled on this connector; `tools`
            lists them. Disable them first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /tenants/{id}/connectors/{connectorId}/test:
    post:
      operationId: testTenantConnector
      summary: Run a live connection test
      description: |
        Has the executor, the one function that decrypts a credential, make
        one read-only probe against the configured system with the stored
        credential (for ServiceNow: an authenticated Table API read of a
        single requested item). Updates the instance's `status`,
        `last_test_at`, and `last_test_message` with the outcome, and nothing
        else.
      tags: [Connectors]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: connectorId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Test outcome (also persisted on the instance)
          content:
            application/json:
              schema:
                type: object
                properties:
                  connector:
                    $ref: "#/components/schemas/TenantConnector"
                  test:
                    $ref: "#/components/schemas/ConnectorTestResult"
        "404":
          $ref: "#/components/responses/NotFound"

  /tenants/{id}/connectors/{connectorId}/credentials:
    put:
      operationId: rotateConnectorCredentials
      summary: Replace a connector's credential, probed first
      description: |
        Replaces the connector's credential in place (execution roadmap
        E2.10). The executor probes the system with the new credential first;
        it is stored only if the system accepts it, and only if the stored
        credential is still the one read before the probe. The connector keeps
        its id, so the tools enabled on it, approvals waiting on it and
        executions not yet dispatched carry on, with the new credential.
        Requires `tenant:write`.

        Where the system allows two credentials at once, as an API key often
        does, add the new one there, replace it here, then remove the old one
        there: nothing fails in between. Where changing it at the system ends
        the old one at once, as a password change does, executions dispatched
        between that change and this call fail.

        A sandbox's credential is minted by Mandaitor and never shown: send no
        body. The sandbox accepts the new secret beside the old one while it
        is probed, and only the new one once it is stored, so a ServiceNow
        sandbox credential shown at provisioning stops working.
      tags: [Connectors]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: connectorId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RotateConnectorCredentials"
      responses:
        "200":
          description: The new credential is stored; the probe that tried it
          content:
            application/json:
              schema:
                type: object
                properties:
                  connector:
                    $ref: "#/components/schemas/TenantConnector"
                  test:
                    $ref: "#/components/schemas/ConnectorTestResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: |
            `CREDENTIAL_CONFLICT`: the credential was replaced by someone else
            meanwhile, so this one was not stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            `CREDENTIAL_REJECTED`: the system did not accept the new
            credential, so nothing was changed. `test` is the probe's outcome.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      test:
                        $ref: "#/components/schemas/ConnectorTestResult"
        "500":
          description: |
            `ROTATION_INCOMPLETE`, for a sandbox only: the new credential is
            stored and works, but the sandbox still accepts the old one too.
            Replace it again to retire the old one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            `PROBE_UNAVAILABLE`: the new credential could not be tried
            against the system, so nothing was changed. Try again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ─── Execution registry (execution roadmap E1.2) ────────────

  /taxonomies:
    get:
      operationId: listTaxonomies
      summary: List the loaded taxonomies
      description: |
        Every taxonomy this deployment loads, with its business actions and
        resource patterns. Mandates name these actions, and every tool maps
        onto exactly one of them. `package` is the npm package a taxonomy
        comes from; it is not the taxonomy id with a prefix.
      tags: [Execution]
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Loaded taxonomies, sorted by id
          content:
            application/json:
              schema:
                type: object
                required: [taxonomies]
                properties:
                  taxonomies:
                    type: array
                    items:
                      $ref: "#/components/schemas/TaxonomyView"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /tools:
    get:
      operationId: listTools
      summary: List the tool registry
      description: |
        Every version of every tool definition. A tool is one operation on one
        kind of system, mapped to one business action. Definitions are code
        and change only through release; tenants enable a version on one of
        their connectors with `PUT /tenants/{id}/tools/{toolId}`.

        A tool is `available` when a loaded taxonomy defines its business
        action. An unavailable tool cannot be enabled.
      tags: [Execution]
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Every tool definition version
          content:
            application/json:
              schema:
                type: object
                required: [tools]
                properties:
                  tools:
                    type: array
                    items:
                      $ref: "#/components/schemas/ToolDefinition"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /tenants/{id}/tools:
    get:
      operationId: getExecutionPolicy
      summary: Get the tenant's execution policy
      description: |
        Which tool version runs on which of the tenant's connectors, in each
        of its environments. `version` rises by one on every change, in any
        environment; an approval's digest records it, so a change to the
        policy forces a fresh approval, in every environment. Before the first
        enablement, `policy_id` is null and `version` is 0.
      tags: [Execution]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
      responses:
        "200":
          description: The execution policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionPolicy"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/tools/{toolId}:
    put:
      operationId: enableTool
      summary: Enable a tool version on a connector, in one environment
      description: |
        Enables one version of a tool in one environment, on one of the
        tenant's connectors, replacing any version enabled there before, and
        raises the policy version. Requires `tenant:write`. The tool must be
        available, and the connector must exist, be of the tool's
        `connector_type` and be in the environment named, `default` when none
        is. An agent's executions use the tool as enabled in the agent's
        environment.
      tags: [Execution]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
        - name: toolId
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnableToolRequest"
      responses:
        "200":
          description: Enabled; the policy's new version
          content:
            application/json:
              schema:
                type: object
                required: [policy_id, version, tool]
                properties:
                  policy_id:
                    type: string
                    maxLength: 64
                  version:
                    type: integer
                    minimum: 1
                  tool:
                    $ref: "#/components/schemas/ToolEnablement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: |
            `TOOL_NOT_FOUND`, `TOOL_VERSION_NOT_FOUND` or `CONNECTOR_NOT_FOUND`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            `TOOL_UNAVAILABLE` (no loaded taxonomy defines the business action;
            `reason` says which), `CONNECTOR_TYPE_MISMATCH`,
            `CONNECTOR_ENVIRONMENT_MISMATCH` (the connector is in another
            environment), or `POLICY_CONFLICT` (the policy changed
            concurrently: read it again and retry)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: disableTool
      summary: Disable a tool in one environment
      description: |
        Disables the tool in one environment and raises the policy version.
        Requires `tenant:write`.
      tags: [Execution]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
        - name: toolId
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
        - name: environment
          in: query
          required: false
          description: The environment to disable it in, `default` when not given
          schema:
            $ref: "#/components/schemas/Environment"
      responses:
        "204":
          description: Disabled
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "`TOOL_NOT_FOUND`, or `TOOL_NOT_ENABLED` for a tool not enabled in that environment"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: "`POLICY_CONFLICT`: the policy changed concurrently"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /tenants/{id}/executions:
    get:
      operationId: listTenantExecutions
      summary: List the tenant's executions
      description: |
        The tenant's executions, newest first, for a person of the tenant:
        the dashboard's Executions page (roadmap E1.11). Each is the
        execution as its agent reads it, and what it rested on: the agent,
        the record and the facts read from it, the approval it needed, and
        what the write did.

        Requires `tenant:read` in the caller's own tenant, which a dashboard
        session has and an API key never does. An agent reads its own
        executions with `GET /executions/{id}` and its session token.

        `state` keeps the executions in that state. Mandaitor filters after
        it reads, so a page can hold fewer than `limit`, even none, with a
        `next_cursor` still to follow; the list ends when there is none.

        An execution waiting for an approval that has expired is listed as
        stored until it is read or swept, within a minute: its
        `approval.expires_at` is then in the past.
      tags: [Execution]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
        - name: state
          in: query
          schema:
            type: string
            enum:
              - AWAITING_APPROVAL
              - AUTHORIZED_NOT_EXECUTED
              - EXECUTING
              - UNKNOWN
              - SUCCEEDED
              - FAILED
              - DENIED
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
        - name: cursor
          in: query
          description: The `next_cursor` of the page before. Another is refused.
          schema:
            type: string
            maxLength: 512
      responses:
        "200":
          description: A page of the tenant's executions, newest first
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    maxItems: 50
                    items:
                      $ref: "#/components/schemas/TenantExecution"
                  next_cursor:
                    type: string
                    maxLength: 512
                    description: Present while the list goes on.
        "400":
          description: |
            `INVALID_REQUEST`: an unknown parameter, a `state` that is not
            one, or a `limit` outside 1 to 50; `errors` lists each problem.
            `INVALID_CURSOR`: a cursor this list did not give out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /tenants/{id}/executions/{executionId}:
    get:
      operationId: getTenantExecution
      summary: Read one of the tenant's executions
      description: |
        One execution, for a person of the tenant, authorised as the list
        is. Another tenant's execution, or an id that does not exist, is
        404. An execution whose approval expired undecided is answered, and
        recorded, as `DENIED` with `APPROVAL_EXPIRED`, as it is when its
        agent reads it.
      tags: [Execution]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
        - name: executionId
          in: path
          required: true
          schema:
            type: string
            pattern: "^exe_[0-9A-Z]{26}$"
            maxLength: 30
      responses:
        "200":
          description: The execution
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantExecution"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: "`EXECUTION_NOT_FOUND`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"

  /tenants/{id}/executions/{executionId}/resolution:
    post:
      operationId: resolveTenantExecution
      summary: Resolve an UNKNOWN execution
      description: |
        A person of the tenant records the outcome of an `UNKNOWN`
        execution that no read-back could establish (roadmap E2.1), after
        looking at the target system. Requires `tenant:write` in the
        caller's own tenant, and a person's session: an API key or an
        agent is refused with 403 `NOT_A_PERSON`.

        **The target's own record comes first.** Mandaitor reads the record
        back once more before it records anything. If that read-back finds
        the write applied, the execution is `SUCCEEDED`; if it proves the
        write absent (and the target never acknowledged it), `FAILED`; either
        way with `RESOLVED_BY_READ_BACK` and a receipt, whatever you sent.
        When that differs from your `outcome`, the answer is 409
        `OUTCOME_CONTRADICTED`, with the execution as it now is.

        **Otherwise your outcome stands**, as `SUCCEEDED` or `FAILED` with
        `RESOLVED_BY_OPERATOR`, your evidence in `resolution`, and no
        receipt: Mandaitor signs only what it read back. Your resolution
        and evidence go on the audit trail. A `FAILED` execution tells its
        agent it may ask again, and a new request is a new write.
      tags: [Execution]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            maxLength: 128
        - name: executionId
          in: path
          required: true
          schema:
            type: string
            pattern: "^exe_[0-9A-Z]{26}$"
            maxLength: 30
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExecutionResolution"
      responses:
        "200":
          description: |
            Resolved: as you said, with `RESOLVED_BY_OPERATOR`; or by a
            read-back that agreed with you, with `RESOLVED_BY_READ_BACK`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantExecution"
        "400":
          description: "`INVALID_REQUEST`: not a valid resolution; `errors` lists each problem."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            `NOT_A_PERSON`, or the caller lacks `tenant:write` in this tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "404":
          description: "`EXECUTION_NOT_FOUND`: no such execution in the caller's tenant"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "409":
          description: |
            `execution` is the execution as it is now.
            - `OUTCOME_CONTRADICTED`: a read-back settled it otherwise than
              you said; it is resolved as the read-back found.
            - `NOT_UNKNOWN`: it is not `UNKNOWN`, or it changed while being
              resolved.
            - `RESOLUTION_IN_PROGRESS`: Mandaitor or another person is
              settling it; read it again in a moment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "503":
          description: |
            `AUDIT_UNAVAILABLE`: the resolution could not be recorded on the
            audit chain, so nothing changed. Try again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"

  /executions:
    post:
      operationId: createExecution
      summary: Request an execution
      description: |
        An agent asks Mandaitor to perform one typed business operation, sent as
        a `mandaitor.tool-request/v1`. The agent names the tool and the record
        (`arguments`, identifiers only). The facts a decision turns on, such as
        the amount or the plant, are read from the target system, never taken
        from the agent.

        Authenticated with the agent's session token from `POST /mcp/token`,
        bound to the agent's key, and nothing else: `Authorization: DPoP mcp_…`
        with a fresh `DPoP` proof for this request (`McpSessionDPoP`). A bearer
        session, API keys, user tokens, PoMs and consent ids are refused with
        401. Each agent may send at most 30 requests a minute (429
        `RATE_LIMITED`).

        **Idempotency.** `(tenant, agent, idempotency_key)` names at most one
        execution, until 24 hours after it reaches a final state.
        - A repeat of the same request returns that execution (200).
        - The same key with a different request is 409 `IDEMPOTENCY_CONFLICT`.
        - A repeat while the first is still being decided is 409
          `IDEMPOTENCY_IN_PROGRESS`, naming the execution. Retry after
          `Retry-After`.

        `purpose` and `mandate_id` are not part of what makes two requests the
        same.

        **A denial is not an error.** It is 201 with the execution in `DENIED`,
        recorded on the audit chain before it is returned. When the audit
        event cannot be written, nothing is created and the answer is 503
        `AUDIT_UNAVAILABLE`.

        **Which mandates count.** The decision reads every `ACTIVE` mandate
        naming the agent as its delegate, by its DID or its agent id. An
        `ALLOW` mandate counts only if it names the tool's business action
        exactly and was created or approved from a person's session. A
        `DENY` mandate counts whoever made it, and wins. More than 100 such
        mandates is `DENIED` with `TOO_MANY_MANDATES`: the decision never
        rests on part of them. `mandate_id` is a hint, and a hint at a
        mandate that does not count is `DENIED` with `HINT_NOT_COVERING`.

        **The record.** The decision reads the record the arguments name from
        the target system, and decides on its facts: the mandate's limits,
        thresholds and validity, and the tool's preconditions. When the record
        cannot be read, nothing is decided: 503 `TARGET_UNAVAILABLE`.

        **An `ALLOW` is answered once it is recorded.** The answer is 201
        with the execution in `AUTHORIZED_NOT_EXECUTED`, and nothing is
        written yet. Mandaitor's worker then takes it from a queue, usually
        within seconds: it rechecks it against the mandates and the record as
        they are now, writes it once, and reads it back. Read the outcome
        with `GET /executions/{id}`: `SUCCEEDED`, `FAILED`, or `UNKNOWN`
        when the target's answer and the read-back leave it open. `DENIED`
        means the recheck refused it, and `decision` is then that refusal.
        Every step is recorded on the audit chain before it is taken, and a
        step that cannot be recorded is not taken: the execution waits in
        `AUTHORIZED_NOT_EXECUTED`, with nothing written, or in `EXECUTING`,
        and the worker takes it up again.

        **An `UNKNOWN` execution is read back again**, a minute later and
        then less and less often, for about 41 hours. Nothing is ever written
        again. A read-back that finds the write applied, or proves it absent,
        makes it `SUCCEEDED` or `FAILED` with a receipt. When none does, it
        stays `UNKNOWN` until a person of the tenant resolves it.

        **A decision that needs a person's approval waits for it.** The
        execution is answered in `AWAITING_APPROVAL`, with nothing written.
        `approval` names the consent in the approver's inbox, the approval
        digest the approver must send back, and when the approval expires (24
        hours at most, never past the mandate's own expiry). The approver
        decides it with `POST /executions/{id}/approval`, and an approved
        execution is then rechecked, written and read back by the worker. An approval that
        expires undecided ends the execution in `DENIED` with
        `APPROVAL_EXPIRED`. An agent may raise 20 approval requests a day;
        past that a decision that needs one is `DENIED` with
        `APPROVAL_LIMIT_REACHED`: the excess is refused, not queued.

        **Phase 1.** Executions run only against Mandaitor's hosted sandboxes:
        a tool whose connector points elsewhere is `DENIED` with
        `CONNECTOR_NOT_PERMITTED`.
      tags: [Execution]
      security:
        - McpSessionDPoP: []
      parameters:
        - $ref: "#/components/parameters/DPoPProof"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ToolRequest"
      responses:
        "200":
          description: A repeat of an earlier request; its execution in its current state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Execution"
        "201":
          description: |
            Created, in the state the decision put it: `DENIED`,
            `AWAITING_APPROVAL`, or `AUTHORIZED_NOT_EXECUTED` for an ALLOW
            the worker writes next.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Execution"
        "400":
          description: |
            `INVALID_REQUEST`: the envelope is not a valid
            `mandaitor.tool-request/v1` (an unknown key, another `contract`,
            `on_behalf_of`). `INVALID_ARGUMENTS`: `arguments` do not match the
            tool's `arguments_schema`. `errors` lists each problem.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "401":
          description: |
            `invalid_token`: no agent session token, a bearer one, or one that
            has expired or ended because its agent was suspended or revoked.
            `invalid_dpop_proof`: no proof, more than one, or one that is not
            for this request and this token, is not signed by the session's
            key, is outside its window, or was used before.
          headers:
            WWW-Authenticate:
              description: '`DPoP algs="ES256 ES384", error="…", error_description="…"`'
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "403":
          description: |
            `TOOL_NOT_ENABLED`: the tool is unknown, not enabled for the tenant,
            pinned to a version other than the enabled one, or cannot run on its
            connector. These cases share one answer, so the response does not
            reveal which tools exist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "409":
          description: |
            `IDEMPOTENCY_CONFLICT`: the key names a different request.
            `IDEMPOTENCY_IN_PROGRESS`: the key's request is still being decided;
            `execution_id` names it. Retry after `Retry-After`.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "429":
          description: "`RATE_LIMITED`: more than 30 requests by this agent in the current minute"
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "503":
          description: |
            `AUDIT_UNAVAILABLE`: the decision could not be recorded on the audit
            chain, so no execution was created. `TARGET_UNAVAILABLE`: the record
            could not be read from the target system, so nothing was decided.
            Either way, retry after `Retry-After`, with the same idempotency
            key.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"

  /executions/{id}:
    get:
      operationId: getExecution
      summary: Read one of the agent's executions
      description: |
        Authenticated like `POST /executions`. An agent reads only its own
        executions; another agent's or another tenant's execution is 404, as is
        an id that does not exist. An execution whose approval expired
        undecided is answered, and recorded, as `DENIED` with
        `APPROVAL_EXPIRED`.

        This is how an agent learns the outcome of what it asked for: read
        it until it is final (`DENIED`, `SUCCEEDED`, `FAILED`) or waits for
        an approval. A few seconds between reads is enough; the SDK's
        `waitForExecution` does it.
      tags: [Execution]
      security:
        - McpSessionDPoP: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: "^exe_[0-9A-Z]{26}$"
            maxLength: 30
        - $ref: "#/components/parameters/DPoPProof"
      responses:
        "200":
          description: The execution
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Execution"
        "401":
          description: "`invalid_token` or `invalid_dpop_proof`, as for `POST /executions`"
          headers:
            WWW-Authenticate:
              description: '`DPoP algs="ES256 ES384", error="…", error_description="…"`'
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "404":
          description: "`EXECUTION_NOT_FOUND`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"

  /executions/{id}/approval:
    post:
      operationId: decideExecutionApproval
      summary: Approve or reject an execution that waits for your approval
      description: |
        The person an execution's approval names decides it. Only a person's
        session is accepted: a dashboard session, or one from the tenant's
        own identity provider. An API key or an agent is refused with 403
        `NOT_A_PERSON`, and anyone the approval does not name with 403
        `NOT_THE_APPROVER`. When the mandate requires MFA, the session must
        have been signed in with it: every dashboard session is, and a
        session from the tenant's identity provider is when its token's
        `amr` says `mfa` (403 `MFA_REQUIRED` otherwise).

        **To approve**, send the `approval_digest` you were shown. The
        approval is granted only when it equals the digest the approval was
        raised with and one computed again from a fresh read of the record,
        under the mandates and the execution policy as they are now. The
        answer is the execution in `AUTHORIZED_NOT_EXECUTED`, approved.
        Mandaitor's worker then rechecks it, writes it once and reads it
        back, as for a decision that needs no approval; the dashboard and
        `GET /tenants/{id}/executions/{executionId}` show the outcome.

        **When something changed**, nothing is written:
        - The record, the mandates or the policy changed since the approval
          was raised, and the decision still needs an approval: a new one is
          raised, with its own consent and digest, and the answer is 409
          `DIGEST_MISMATCH` carrying the execution with it.
        - The record changed so that it no longer needs an approval: the
          execution is `DENIED` with `RECORD_CHANGED`, and the agent asks
          again (409 `DIGEST_MISMATCH`).
        - The decision on the record as it is now is a denial, the mandate
          is no longer in force, the agent no longer active, or the tool no
          longer enabled: the execution is `DENIED` (409 `EXECUTION_DENIED`).

        **To reject**, send `REJECT`; no digest is needed. The execution is
        `DENIED` with `APPROVAL_REJECTED`.

        An approval is decided once. A second, concurrent decision is
        refused with 409 `APPROVAL_IN_PROGRESS` before it records anything.
      tags: [Execution]
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: "^exe_[0-9A-Z]{26}$"
            maxLength: 30
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExecutionApprovalDecision"
      responses:
        "200":
          description: |
            Decided: rejected, or approved and queued for the worker to
            write. The execution as it is now.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Execution"
        "400":
          description: "`INVALID_REQUEST`: not a valid decision; `errors` lists each problem."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "401":
          description: Missing or invalid credentials, refused by the gateway
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: |
            `NOT_A_PERSON`, `NOT_THE_APPROVER` or `MFA_REQUIRED`. Nothing
            about the execution is shown.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "404":
          description: "`EXECUTION_NOT_FOUND`: no such execution in the caller's tenant"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "409":
          description: |
            Nothing was approved. `execution` is the execution as it is now,
            for the approver.
            - `DIGEST_MISMATCH`: the digest sent is not the approval's, or
              what it covered changed (see above).
            - `APPROVAL_SUPERSEDED`: `consent_id` names an approval a newer
              one replaced.
            - `APPROVAL_EXPIRED`: it expired undecided; the execution is now
              `DENIED`.
            - `EXECUTION_DENIED`: denied as described above.
            - `NOT_AWAITING_APPROVAL`: the execution is not waiting for an
              approval, for instance because it was already decided.
            - `APPROVAL_IN_PROGRESS`: another decision on it is under way.
            - `TOOL_NOT_ENABLED`: its tool version is no longer defined.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"
        "503":
          description: |
            `TARGET_UNAVAILABLE`: the record could not be read to check it,
            so nothing was approved. `AUDIT_UNAVAILABLE`: the decision could
            not be recorded on the audit chain, so nothing changed. Retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecutionError"

  # ═══════════════════════════════════════════════════════════
  # Agent Marketplace (separate API: marketplace.mandaitor.io)
  # ═══════════════════════════════════════════════════════════

  /marketplace/listings:
    post:
      tags: [Agent Marketplace]
      summary: Create a new marketplace listing
      operationId: createListing
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateListingRequest"
      responses:
        "201":
          description: Listing created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketplaceListing"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      tags: [Agent Marketplace]
      summary: List own marketplace listings
      operationId: listOwnListings
      security:
        - BearerAuth: []
      parameters:
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
      responses:
        "200":
          description: Paginated list of own listings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedListings"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/listings/{listingId}:
    parameters:
      - name: listingId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Agent Marketplace]
      summary: Get listing details
      operationId: getListing
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Listing details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketplaceListing"
        "404":
          description: Listing not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      tags: [Agent Marketplace]
      summary: Update listing
      operationId: updateListing
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateListingRequest"
      responses:
        "200":
          description: Listing updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketplaceListing"
        "403":
          description: Not the listing owner
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Agent Marketplace]
      summary: Archive listing (soft-delete)
      operationId: archiveListing
      security:
        - BearerAuth: []
      responses:
        "204":
          description: Listing archived
        "403":
          description: Not the listing owner
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/listings/{listingId}/publish:
    parameters:
      - name: listingId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [Agent Marketplace]
      summary: Publish a draft listing
      operationId: publishListing
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Listing published
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketplaceListing"
        "409":
          description: Listing not in DRAFT status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/listings/{listingId}/suspend:
    parameters:
      - name: listingId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [Agent Marketplace]
      summary: Suspend a published listing
      operationId: suspendListing
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Listing suspended
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketplaceListing"
        "409":
          description: Listing not in PUBLISHED status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/discover:
    get:
      tags: [Agent Marketplace]
      summary: Discover agents (public)
      operationId: discoverAgents
      security: []
      x-auth-exemption-reason: >-
        Public agent discovery listing; returns only published marketplace entries.
      parameters:
        - name: q
          in: query
          description: Free-text search
          schema:
            type: string
        - name: category
          in: query
          schema:
            $ref: "#/components/schemas/CapabilityCategory"
        - name: min_trust_score
          in: query
          schema:
            type: number
            minimum: 0
            maximum: 1
        - name: min_rating
          in: query
          schema:
            type: number
            minimum: 1
            maximum: 5
        - name: pricing_model
          in: query
          schema:
            $ref: "#/components/schemas/PricingModel"
        - name: sort_by
          in: query
          schema:
            type: string
            enum: [trust_score, rating, invocations, response_time, created_at]
        - name: sort_order
          in: query
          schema:
            type: string
            enum: [asc, desc]
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
      responses:
        "200":
          description: Discovery results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiscoveryResult"
        "400":
          description: Invalid query parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/discover/match:
    post:
      tags: [Agent Marketplace]
      summary: Find best-matching agents for a task
      operationId: matchAgents
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_description]
              properties:
                task_description:
                  type: string
                  description: Natural language description of the task
                required_capabilities:
                  type: array
                  items:
                    type: string
                  description: Specific capability IDs needed
                max_results:
                  type: integer
                  minimum: 1
                  maximum: 20
                  default: 5
      responses:
        "200":
          description: Matched agents ranked by relevance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiscoveryResult"
        "400":
          description: Missing required field (capability_id)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/reviews:
    post:
      tags: [Agent Marketplace]
      summary: Submit a review
      operationId: createReview
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateReviewRequest"
      responses:
        "201":
          description: Review created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentReview"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/reviews/{listingId}:
    parameters:
      - name: listingId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Agent Marketplace]
      summary: List reviews for a listing (public)
      operationId: listReviews
      security: []
      x-auth-exemption-reason: >-
        Public reviews for a published marketplace listing.
      parameters:
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
      responses:
        "200":
          description: Paginated reviews
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedReviews"
        "400":
          description: Missing listingId
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /marketplace/reviews/{listingId}/{reviewId}:
    parameters:
      - name: listingId
        in: path
        required: true
        schema:
          type: string
      - name: reviewId
        in: path
        required: true
        schema:
          type: string
    delete:
      tags: [Agent Marketplace]
      summary: Delete own review
      operationId: deleteReview
      security:
        - BearerAuth: []
      responses:
        "204":
          description: Review deleted
        "403":
          description: Not the review author
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ─── Partner Workspace: knowledge catalog (media) ─────────────

  /partner/media/assets:
    get:
      tags: [Partner Workspace]
      summary: List knowledge assets
      operationId: listPartnerMediaAssets
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: classification
          in: query
          schema:
            $ref: "#/components/schemas/MediaClassification"
        - name: lifecycle_area
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/KnowledgeAssetStatus"
      responses:
        "200":
          description: Knowledge assets visible to the caller
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedKnowledgeAssets"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Partner Workspace]
      summary: Create or update a knowledge asset (expert catalog form)
      operationId: upsertPartnerMediaAsset
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpsertKnowledgeAssetRequest"
      responses:
        "200":
          description: Existing asset updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KnowledgeAssetEnvelope"
        "201":
          description: Asset created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KnowledgeAssetEnvelope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"

  /partner/media/assets/publish:
    post:
      tags: [Partner Workspace]
      summary: One-shot document publish (simplified flow)
      description: |
        Creates a single-file document asset from a title + file metadata,
        deriving all catalog fields server-side, and returns a presigned S3
        PUT for the bytes. Send the returned `upload_headers` verbatim with
        the PUT — when a `sha256` was announced, the checksum header is part
        of the signature and S3 rejects non-matching bytes. Finish with
        `publish-complete`.
      operationId: publishPartnerDocument
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublishDocumentRequest"
      responses:
        "201":
          description: Document registered; upload the bytes next
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublishDocumentResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"

  /partner/media/assets/{assetId}:
    parameters:
      - name: assetId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Partner Workspace]
      summary: Get a knowledge asset with its versions and files
      operationId: getPartnerMediaAsset
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Asset detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KnowledgeAssetDetail"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /partner/media/assets/{assetId}/versions:
    parameters:
      - name: assetId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [Partner Workspace]
      summary: Register an immutable media version (expert catalog form)
      description: |
        Version strings are immutable — re-posting an existing one answers
        409. `approved_by`/`approved_at` are set server-side when `status`
        is `approved` and never accepted from the request. The asset's
        `current_version` only advances when the new version is genuinely
        newer (numeric dotted versions compare numerically).
      operationId: registerPartnerMediaVersion
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterMediaVersionRequest"
      responses:
        "201":
          description: Version registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    $ref: "#/components/schemas/MediaVersion"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /partner/media/assets/{assetId}/publish-complete:
    parameters:
      - name: assetId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [Partner Workspace]
      summary: Confirm a document upload landed and publish the asset
      description: |
        Verifies the uploaded object exists (and, when a SHA-256 was
        announced, that S3's stored checksum matches), records the real
        object size, and flips the asset and version to `uploaded`.
      operationId: completePartnerDocument
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Document published
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublishCompleteResponse"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /partner/media/assets/{assetId}/files/signed-url:
    parameters:
      - name: assetId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [Partner Workspace]
      summary: Create a short-lived signed download URL for a file
      operationId: createPartnerMediaSignedUrl
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MediaSignedUrlRequest"
      responses:
        "200":
          description: Signed URL issued (download is audited)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaSignedUrlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /partner/media/files/{fileId}/signed-url:
    parameters:
      - name: fileId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [Partner Workspace]
      summary: Create a signed download URL addressing the file by id
      description: Body must carry `asset_id` and `version`; the path's file id overrides any body `file_id`.
      operationId: createPartnerMediaSignedUrlByFile
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MediaSignedUrlRequest"
      responses:
        "200":
          description: Signed URL issued (download is audited)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaSignedUrlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Partner Workspace: engagement artifacts ──────────────────

  /partner/projects/{projectId}/artifacts:
    parameters:
      - name: projectId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Partner Workspace]
      summary: List an engagement's artifacts
      operationId: listEngagementArtifacts
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Artifacts authored so far (at most one per type)
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/EngagementArtifact"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /partner/projects/{projectId}/artifacts/{artifactType}:
    parameters:
      - name: projectId
        in: path
        required: true
        schema:
          type: string
      - name: artifactType
        in: path
        required: true
        schema:
          $ref: "#/components/schemas/EngagementArtifactType"
    get:
      tags: [Partner Workspace]
      summary: Get one artifact
      description: |
        Answers `200` with `"artifact": null` when the artifact hasn't been
        authored yet — an expected state on fresh engagements, not an error.
        A missing engagement still answers 404.
      operationId: getEngagementArtifact
      security:
        - BearerAuth: []
      responses:
        "200":
          description: The artifact, or null when not yet authored
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EngagementArtifactEnvelope"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Partner Workspace]
      summary: Create or update an artifact (bumps its version)
      operationId: upsertEngagementArtifact
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payload]
              properties:
                payload:
                  type: object
                  description: Type-specific artifact payload (validated per artifact type)
                  additionalProperties: true
      responses:
        "200":
          description: Artifact updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EngagementArtifactEnvelope"
        "201":
          description: Artifact created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EngagementArtifactEnvelope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /partner/projects/{projectId}/artifacts/{artifactType}/submit:
    parameters:
      - name: projectId
        in: path
        required: true
        schema:
          type: string
      - name: artifactType
        in: path
        required: true
        schema:
          $ref: "#/components/schemas/EngagementArtifactType"
    post:
      tags: [Partner Workspace]
      summary: Submit an artifact for review
      operationId: submitEngagementArtifact
      security:
        - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reviewer_role:
                  type: string
                notes:
                  type: string
      responses:
        "200":
          description: Artifact moved to IN_REVIEW with a review task
          content:
            application/json:
              schema:
                type: object
                properties:
                  artifact:
                    $ref: "#/components/schemas/EngagementArtifact"
                  review_task:
                    $ref: "#/components/schemas/PartnerReviewTask"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  # ── Organisation model ──────────────────────────────────────
  /org-units:
    post:
      operationId: createOrgUnit
      summary: Create an organisational unit
      tags: [Organisation]
      description: |
        Creates an accountability unit inside the tenant. Every unit names an
        accountable owner; the hierarchy is bounded to four levels so aggregated
        reporting stays readable.

        The first unit created may omit `parent_id` and becomes the root. Any
        later unit must name an existing parent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrgUnitRequest"
      responses:
        "201":
          description: Org unit created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgUnit"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller lacks the `tenant:write` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Named parent unit does not exist
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgHierarchyError"
        "409":
          description: |
            Placement conflicts with the existing structure — a second root, a
            depth beyond four levels, a cycle, or a parent/child kind pairing
            that is not allowed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgHierarchyError"
    get:
      operationId: listOrgUnits
      summary: List organisational units
      tags: [Organisation]
      description: |
        Returns every unit of the tenant. The result is not paginated: the
        hierarchy is bounded by design and a management view needs the whole
        tree to render it.
      parameters:
        - name: status
          in: query
          description: Restrict to active or archived units
          schema:
            type: string
            enum: [ACTIVE, ARCHIVED]
      responses:
        "200":
          description: Org units of the tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/OrgUnit"
                  total_count:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /org-units/{id}:
    get:
      operationId: getOrgUnit
      summary: Get an organisational unit
      tags: [Organisation]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Org unit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgUnit"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Org unit not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      operationId: updateOrgUnit
      summary: Update an organisational unit
      tags: [Organisation]
      description: |
        Updates descriptive fields and accountable ownership. `kind`,
        `parent_id`, `path` and `depth` are immutable: moving a unit changes the
        meaning of every historical aggregation that referenced it, so a move is
        modelled as archive plus create rather than an in-place edit.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateOrgUnitRequest"
      responses:
        "200":
          description: Updated org unit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgUnit"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller lacks the `tenant:write` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Org unit not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: archiveOrgUnit
      summary: Archive an organisational unit
      tags: [Organisation]
      description: |
        Archives the unit. This is a soft delete: mandates and audit events keep
        referencing it, so hard deletion would break the evidence trail the
        platform exists to preserve.

        A unit with active children cannot be archived — archiving a parent
        would leave its children unreachable in the tree while still active.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Archived org unit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgUnit"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller lacks the `tenant:write` permission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Org unit not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Unit still has active children
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgHierarchyError"

  /org-units/{id}/subtree:
    get:
      operationId: getOrgUnitSubtree
      summary: Resolve a unit and everything below it
      tags: [Organisation]
      description: |
        Returns the unit together with all descendants, resolved from the
        materialised ancestor path in a single read. This is the aggregation
        boundary for management reporting: "everything below this division".
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Unit and its descendants
          content:
            application/json:
              schema:
                type: object
                properties:
                  root:
                    $ref: "#/components/schemas/OrgUnit"
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/OrgUnit"
                  total_count:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Org unit not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ═══════════════════════════════════════════════════════════════
  # COMPONENTS
  # ═══════════════════════════════════════════════════════════════
  /governance/posture:
    get:
      operationId: getGovernancePosture
      summary: Aggregated governance posture for the tenant
      description: |
        Aggregated governance indicators over a bounded window, for oversight
        roles rather than operators. Answers "is the delegation posture
        acceptable, and where is it worst" — a question the per-mandate surfaces
        cannot.

        **Rates carry their denominators.** Every rate reports `numerator` and
        `denominator`, and `value` is `null` when the denominator is zero. A
        denial rate of zero from four checks and one from no checks are different
        statements; a bare `0` would conflate them.

        **Coverage is explicit.** The read is bounded in both window length and
        index pages. When either bound truncates the read, `coverage.truncated`
        is `true` and a `WINDOW_TRUNCATED` caveat is present. Callers must not
        present figures as complete without checking it.

        **Caveats are part of the answer.** They flag conditions that make an
        indicator misleading — no verifications in the window, a small latency
        sample, unassigned mandates, unscoreable risk profiles. A dashboard that
        renders the numbers and drops the caveats will mislead its reader.
      tags:
        - Governance
      security:
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - name: window_days
          in: query
          required: false
          description: |
            Length of the reporting window in days. Rejected rather than clamped
            when out of range, so the response always answers the question that
            was asked.
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 30
      responses:
        "200":
          description: Aggregated posture.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GovernancePosture"
        "400":
          description: Missing tenant context or window outside the permitted range.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Missing `mandates:read` scope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    McpSessionAuth:
      type: http
      scheme: bearer
      bearerFormat: mcp_ session token
      description: |
        An agent's bearer session token from `POST /mcp/token` (`mcp_…`),
        issued without a DPoP proof. It lasts 15 minutes. A session issued
        for the MCP server (`resource`, RFC 8707) is accepted only by
        `POST /mcp`. Any other is accepted only by `/mcp/sessions/{id}`;
        `/executions` takes only a session bound to
        the agent's key (`McpSessionDPoP`). Every other route refuses both.
        It ends when its agent is suspended or revoked.
    McpSessionDPoP:
      type: http
      scheme: DPoP
      description: |
        An agent's session token from `POST /mcp/token` bound to a key the
        client holds (DPoP, RFC 9449): issued to a token request that carried
        a DPoP proof, with `token_type` `DPoP`, for an hour. It is sent as
        `Authorization: DPoP mcp_…`, and every request carries a `DPoP`
        header with a fresh proof: a JWT of `typ` `dpop+jwt`, signed with
        ES256 or ES384 by that key, whose public half is its `jwk` header,
        with a unique `jti`, the request's method (`htm`) and URL without
        query (`htu`), `iat` within the last five minutes, and the
        base64url SHA-256 of the token (`ath`). Each proof is accepted once.
        It is accepted where a bearer session is, by the same audience rule,
        and alone by `/executions`. It ends when its agent is suspended or
        revoked.

  parameters:
    MandateId:
      name: id
      in: path
      required: true
      schema:
        type: string
        example: "mdt_01HXYZ..."
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        default: 50
        minimum: 1
        maximum: 200
    Cursor:
      name: cursor
      in: query
      schema:
        type: string
        description: Base64url-encoded pagination cursor
    DPoPProof:
      name: DPoP
      in: header
      required: true
      description: |
        A fresh DPoP proof (RFC 9449) for this request and this session
        token, signed by the key the session is bound to (`McpSessionDPoP`).
      schema:
        type: string
        maxLength: 4096
    DPoPProofIfBound:
      name: DPoP
      in: header
      required: false
      description: |
        Required with a session bound to the agent's key, sent as
        `Authorization: DPoP mcp_…`: a fresh proof for this request and this
        token (`McpSessionDPoP`). Not sent with a bearer session.
      schema:
        type: string
        maxLength: 4096

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AgentSessionUnauthorized:
      description: |
        No usable agent session: `invalid_token` for a missing, expired or
        ended session, or a bound one sent as `Bearer`; `invalid_dpop_proof`
        for a bound session whose proof is missing, repeated, not for this
        request and token, not signed by the session's key, outside its
        window, or used before.
      headers:
        WWW-Authenticate:
          description: |
            `Bearer`, with `error="invalid_token"` when a token was sent. For a
            session bound to a key, `DPoP algs="ES256 ES384", error="…", error_description="…"`.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: Insufficient permissions
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: State conflict
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    # ─── Core Schemas ─────────────────────────────────────────

    ApiScope:
      type: string
      description: |
        API-key scope identifier. Enforced at request time on API-key-
        authenticated calls only — Cognito dashboard callers bypass
        scope checks because tenant isolation is the primary guard
        there.
          * `mandates:read` — GET /mandates, GET /mandates/{id}, GET
            /mandates/{id}/events, GET /events, GET /events/{id}.
          * `mandates:write` — POST /mandates and all lifecycle
            transitions (approve, revoke, suspend, reactivate).
          * `verify` — POST /verify.
      enum:
        - mandates:read
        - mandates:write
        - verify

    Subject:
      type: object
      description: Represents an entity (human, AI agent, service, or organization) that participates in a mandate as either principal or delegate.
      required: [type, subject_id]
      properties:
        type:
          type: string
          enum: [HUMAN, AI_AGENT, SERVICE, ORGANIZATION]
        subject_id:
          type: string
          example: "construction:agent:validate-agent-v2"
        display_name:
          type: string
        identity_provider:
          type: string
        identity_token_hash:
          type: string

    Scope:
      type: object
      description: Defines the actions, resources, and effect (ALLOW/DENY) that a mandate authorizes, with optional conditions.
      required: [actions, resources]
      properties:
        actions:
          type: array
          items:
            type: string
          example: ["construction.validation.approve"]
        resources:
          type: array
          items:
            type: string
          example: ["construction:project:*/zone:*/installation:*"]
        effect:
          type: string
          enum: [ALLOW, DENY]
          default: ALLOW
        conditions:
          type: object

    Constraints:
      type: object
      description: |
        Optional restrictions on mandate usage.

        **Not every key here is enforced.** `POST /verify` evaluates the
        mandate validity window (`time`), `require_mfa`, and
        `escalation_rules.deviation_above_percent`. The keys marked
        `deprecated` below are accepted and stored but are **not evaluated** —
        they do not restrict anything. The `unsupported_constraints` field on
        the creation response names any you send, and every verification
        response reports `evaluated_constraints` and `unevaluated_constraints`
        for the mandate that matched.

        `transaction_limits` and `rate_limits` are the exceptions worth
        knowing: executions (`POST /executions`) enforce them, in the shapes
        the execution guide gives; `POST /verify` does not. In any other shape
        they restrict nothing, a mandate carrying them backs no execution, and
        `unsupported_constraints` names them.

        These keys are deprecated as of 2026-09-10 and will be **rejected** at
        creation after the 90-day deprecation window for Stable endpoints. See
        the Beta Limitations page.
      properties:
        max_uses:
          type: integer
          deprecated: true
          description: NOT ENFORCED. Accepted and stored; no code path evaluates it.
        time_window:
          type: object
          deprecated: true
          description: >-
            NOT ENFORCED under this name. The mandate validity window is stored
            as `time` (`not_before` / `expires_at`) and that IS enforced; a
            `time_window` sent inside `constraints` is ignored.
          properties:
            start:
              type: string
              format: date-time
            end:
              type: string
              format: date-time
        time:
          type: object
          description: >-
            ENFORCED. The mandate's validity window, checked on every
            verification.
          properties:
            not_before:
              type: string
              format: date-time
            expires_at:
              type: string
              format: date-time
        geo_fence:
          type: object
          deprecated: true
          description: NOT ENFORCED. Accepted and stored; no code path evaluates it.
          properties:
            allowed_regions:
              type: array
              items:
                type: string
        require_mfa:
          type: boolean
          description: >-
            ENFORCED. Denies with `MFA_REQUIRED` unless the request context
            carries `mfa_timestamp`, `amr` including `mfa`, or `loa` of
            SUBSTANTIAL or HIGH.
        ip_whitelist:
          type: array
          deprecated: true
          description: NOT ENFORCED. Accepted and stored; no code path evaluates it.
          items:
            type: string
        transaction_limits:
          type: object
          deprecated: true
          additionalProperties: true
          description: >-
            NOT ENFORCED by `POST /verify`. A Proof-of-Mandate issued by
            `/verify` lists it under `unenforced_constraints` rather than in
            `constraints_snapshot`. The execution path (`POST /executions`)
            enforces `max_amount`, a cap on one execution's amount, and
            `max_total`, a cap on what the mandate's executions add up to in a
            calendar window in UTC; a mandate carrying any other key here backs
            no execution.
          properties:
            max_amount:
              type: object
              required: [currency, value]
              additionalProperties: false
              properties:
                currency:
                  type: string
                  pattern: "^[A-Z]{3}$"
                value:
                  type: string
                  pattern: "^(0|[1-9][0-9]{0,17})(\\.[0-9]{1,18})?$"
            max_total:
              type: array
              maxItems: 4
              description: >-
                Execution path only. At most one limit per window. An execution
                counts in the window in which it is written, from the moment
                Mandaitor reserves it before the write until its write is proved
                absent; one whose outcome is `UNKNOWN` keeps counting.
              items:
                type: object
                required: [amount, window]
                additionalProperties: false
                properties:
                  amount:
                    type: object
                    required: [currency, value]
                    additionalProperties: false
                    properties:
                      currency:
                        type: string
                        pattern: "^[A-Z]{3}$"
                      value:
                        type: string
                        pattern: "^(0|[1-9][0-9]{0,17})(\\.[0-9]{1,18})?$"
                  window:
                    $ref: "#/components/schemas/LimitWindow"
        rate_limits:
          type: object
          deprecated: true
          additionalProperties: true
          description: >-
            NOT ENFORCED by `POST /verify`. For request-rate limits see the
            Rate Limiting guide — those are enforced per tenant at the gateway,
            not per mandate. The execution path (`POST /executions`) enforces
            `max_count`, a cap on how many executions the mandate backs in a
            calendar window in UTC; a mandate carrying any other key here backs
            no execution.
          properties:
            max_count:
              type: array
              maxItems: 4
              description: >-
                Execution path only. At most one limit per window. An execution
                counts as `max_total` describes.
              items:
                type: object
                required: [count, window]
                additionalProperties: false
                properties:
                  count:
                    type: integer
                    minimum: 1
                    maximum: 1000000000
                  window:
                    $ref: "#/components/schemas/LimitWindow"
        escalation_rules:
          type: object
          description: >-
            PARTIALLY ENFORCED. `deviation_above_percent` is evaluated by
            `POST /verify` when the request supplies
            `context.deviation_percent`. `amount_above` is evaluated by the MCP
            authorization path only.
          properties:
            deviation_above_percent:
              type: number
            amount_above:
              type: object
              properties:
                currency:
                  type: string
                value:
                  type: string
            escalate_to:
              type: string
            escalation_method:
              type: string

    Proof:
      type: object
      description: Cryptographic proof of mandate creation, including the creation method, issuer, mandate hash, and KMS signature.
      properties:
        creation_method:
          type: string
          enum: [API_KEY, EUDI_WALLET, OAUTH2, MANUAL]
        issuer:
          type: string
        created_by:
          type: string
        created_by_type:
          type: string
          enum: [NATURAL_PERSON, SERVICE]
          description: |
            Whether a person's session (`NATURAL_PERSON`) or an API key
            (`SERVICE`) created the mandate, as authenticated, whatever
            principal it names. An execution counts an `ALLOW` mandate only if
            a person created or approved it. Absent on mandates created before
            it was recorded.
        created_by_auth_method:
          type: string
          maxLength: 64
          description: The identity provider that authenticated the creator, such as `COGNITO` or `API_KEY`.
        mandate_hash:
          type: string
        signature:
          type: string
        eidas_loa:
          type: string

    Mandate:
      type: object
      description: A verifiable delegation of authority from a principal to a delegate, with defined scope, constraints, and lifecycle status.
      properties:
        mandate_id:
          type: string
          example: "mdt_01HXYZ..."
        tenant_id:
          type: string
        version:
          type: integer
        status:
          type: string
          enum: [DRAFT, ACTIVE, SUSPENDED, REVOKED, EXPIRED]
        principal:
          $ref: "#/components/schemas/Subject"
        delegate:
          $ref: "#/components/schemas/Subject"
        scope:
          $ref: "#/components/schemas/Scope"
        constraints:
          $ref: "#/components/schemas/Constraints"
        proof:
          $ref: "#/components/schemas/Proof"
        metadata:
          type: object
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: |
            When the mandate stops authorizing, if it was set at creation. It and
            `constraints.time.expires_at` both bound the mandate; the earlier wins.
        vui:
          $ref: "#/components/schemas/VerifiableUserInstruction"
        approval:
          type: object
          description: |
            Who approved a mandate that was created `PENDING_APPROVAL`, as
            authenticated. Absent on mandates created `ACTIVE`, and on
            mandates approved before it was recorded.
          properties:
            approved_by:
              type: string
              maxLength: 256
            approved_by_type:
              type: string
              enum: [NATURAL_PERSON, SERVICE]
            approved_by_auth_method:
              type: string
              maxLength: 64
            approved_at:
              type: string
              format: date-time

    CreateMandateRequest:
      type: object
      description: Request body for creating a new mandate. Requires principal, delegate, and scope. Optionally includes constraints, metadata, expiration, and taxonomy reference.
      required: [principal, delegate, scope]
      properties:
        principal:
          $ref: "#/components/schemas/Subject"
        delegate:
          $ref: "#/components/schemas/Subject"
        scope:
          $ref: "#/components/schemas/Scope"
        constraints:
          $ref: "#/components/schemas/Constraints"
        metadata:
          type: object
        expires_at:
          type: string
          format: date-time
          description: |
            When the mandate stops authorizing; must be in the future. Enforced
            together with `constraints.time.expires_at`: the earlier wins.
        taxonomy_id:
          type: string
          description: Taxonomy identifier to validate the mandate scope against (e.g., "construction", "realestate", "venture").
          example: "construction"
        taxonomy_version:
          type: string
          description: |
            Versioned taxonomy reference. Accepts either a plain ID (uses latest version)
            or `id@semver` format (validates exact version match).
          pattern: "^[a-z][a-z0-9-]*(@\\d+\\.\\d+\\.\\d+(-[a-z0-9.]+)?)?$"
          example: "construction@2.0.0"
        require_approval:
          type: boolean
          description: When true, the mandate starts in PENDING_APPROVAL status and requires explicit approval before becoming ACTIVE.
          default: false
        intent:
          $ref: "#/components/schemas/MandateIntent"
        vui:
          $ref: "#/components/schemas/VUIRequest"

    VUIRequest:
      type: object
      description: |
        **@experimental** Verifiable User Instruction input.
        Captures the human's original instruction at mandate creation time.
        The instruction text will be encrypted at rest and its SHA-256 digest
        embedded into the Proof-of-Mandate Verifiable Credential.
      required: [instruction_text, instruction_channel, principal_confirmation]
      properties:
        instruction_text:
          type: string
          description: The original human instruction (max 2048 characters)
          maxLength: 2048
          example: "Bestelle Büromaterial bis 500€"
        instruction_channel:
          type: string
          enum: [chat, email, voice, api]
          description: Channel through which the instruction was received
          example: "chat"
        principal_confirmation:
          type: object
          required: [method, timestamp]
          properties:
            method:
              type: string
              enum: [explicit_approval, implicit_delegation]
            timestamp:
              type: string
              format: date-time
            evidence_ref:
              type: string
              description: Reference to evidence (chat log URL, email message-id, etc.)
              maxLength: 2048
        constraints_derived:
          type: object
          description: Constraints derived from the instruction (advisory, not enforced)
          properties:
            max_amount:
              type: number
              description: Maximum monetary amount
            allowed_actions:
              type: array
              items:
                type: string
            time_bound:
              type: string
              format: date-time

    VerifiableUserInstruction:
      type: object
      description: |
        **@experimental** Verifiable User Instruction stored on a mandate.
        The instruction_text field contains the KMS-encrypted ciphertext.
      properties:
        instruction_text:
          type: string
          description: KMS-encrypted instruction text (base64)
        instruction_hash:
          type: string
          description: SHA-256 hex digest of the original plaintext instruction
          example: "a1b2c3d4e5f6..."
        instruction_timestamp:
          type: string
          format: date-time
        instruction_channel:
          type: string
          enum: [chat, email, voice, api]
        principal_confirmation:
          type: object
          properties:
            method:
              type: string
              enum: [explicit_approval, implicit_delegation]
            timestamp:
              type: string
              format: date-time
            evidence_ref:
              type: string
        constraints_derived:
          type: object
          properties:
            max_amount:
              type: number
            allowed_actions:
              type: array
              items:
                type: string
            time_bound:
              type: string
              format: date-time

    InstructionResponse:
      type: object
      description: Decrypted instruction response
      properties:
        instruction_text:
          type: string
          description: The original plaintext instruction
        instruction_hash:
          type: string
          description: SHA-256 hex digest for integrity verification
        instruction_timestamp:
          type: string
          format: date-time
        instruction_channel:
          type: string
          enum: [chat, email, voice, api]
        principal_confirmation:
          type: object
          properties:
            method:
              type: string
            timestamp:
              type: string
              format: date-time
            evidence_ref:
              type: string
        mandate_id:
          type: string
          description: The mandate this instruction belongs to

    SystemMode:
      type: object
      description: Current system operational mode with metadata.
      properties:
        mode:
          type: string
          enum: [growth, sustain, maintenance, frozen]
          example: "growth"
        reason:
          type: string
          example: "Default operational mode"
        changed_at:
          type: string
          format: date-time
          nullable: true
        changed_by:
          type: string
          nullable: true
        estimated_duration_minutes:
          type: integer
          nullable: true

    StatusChange:
      type: object
      description: Response returned when a mandate's status is changed (revoked, suspended, or reactivated).
      properties:
        mandate_id:
          type: string
        status:
          type: string
        updated_at:
          type: string
          format: date-time

    VerifyRequest:
      type: object
      description: |
        Request body for the real-time verification endpoint. Specifies the delegate, action,
        and resource to check against active mandates.

        **Alias support:** The server accepts `agent_id` as a legacy alias for
        `delegate_subject_id`. If both fields are present, `delegate_subject_id`
        takes precedence. New integrations should always use `delegate_subject_id`.
      required: [action, resource]
      properties:
        delegate_subject_id:
          type: string
          minLength: 1
          maxLength: 512
          description: "Canonical field. Subject ID of the delegate to verify."
          example: "construction:agent:validate-agent-v2"
        agent_id:
          type: string
          minLength: 1
          maxLength: 512
          description: "**Deprecated** legacy alias for `delegate_subject_id`. Use `delegate_subject_id` for new integrations."
          example: "construction:agent:validate-agent-v2"
          deprecated: true
        action:
          type: string
          minLength: 1
          maxLength: 256
          example: "construction.validation.approve"
        resource:
          type: string
          minLength: 1
          maxLength: 256
          example: "construction:project:proj_ABC123/zone:EG/installation:stk_42"
        context:
          type: object
          description: |
            Additional context for constraint evaluation. When a mandate has
            `require_mfa: true`, the caller must supply MFA proof via one of:
            - `amr` (array of strings) — must include `"mfa"`
            - `loa` (string) — must be `"SUBSTANTIAL"` or `"HIGH"`
            - `mfa_timestamp` (string, ISO 8601) — timestamp of last MFA verification

    VerifyResponse:
      type: object
      description: >-
        Verification result: the ALLOW/DENY decision, the matching mandate,
        the audit event id, reason codes, and — since 2026-09-10 — which of
        the mandate's constraints were evaluated and which were not.
      properties:
        decision:
          type: string
          enum: [ALLOW, DENY]
        mandate_id:
          type: string
        event_id:
          type: string
        reason_codes:
          type: array
          description: "Reason codes for DENY decisions."
          items:
            type: string
            enum:
              [
                NO_MATCHING_MANDATE,
                EXPLICIT_DENY,
                ESCALATION_REQUIRED,
                MFA_REQUIRED,
                APPROVAL_REQUIRED,
              ]
        constraints_remaining:
          type: object
        evaluated_constraints:
          type: array
          description: >-
            Constraint keys on the matched mandate that this decision was
            actually evaluated against. Present whenever a mandate matched; an
            empty array means the mandate carried no constraints this path
            checks, which is different from the field being absent.
          items:
            type: string
        unevaluated_constraints:
          type: array
          description: >-
            Constraint keys the matched mandate carries that `POST /verify` does
            NOT evaluate — `transaction_limits`, `rate_limits`, `max_uses`,
            `geo_fence`, `ip_whitelist`, or anything unrecognised. Absent when
            there are none. A caller that wants a hard failure rather than a
            disclosure should treat a non-empty value here as one.
          items:
            type: string

    VerifyResponseWithPoM:
      description: |
        Extended verification response that optionally includes a
        Proof-of-Mandate Verifiable Credential (SD-JWT VC).

        **Alias support:** The response includes `proof_token` as a legacy
        alias for `proof_of_mandate.compact`. New integrations should use
        `proof_of_mandate.compact`.
      allOf:
        - $ref: "#/components/schemas/VerifyResponse"
        - type: object
          properties:
            proof_of_mandate:
              $ref: "#/components/schemas/ProofOfMandateVC"
            proof_token:
              type: string
              deprecated: true
              description: "**Deprecated** legacy alias for `proof_of_mandate.compact`. Contains the SD-JWT compact serialization string."

    ProofOfMandateVC:
      type: object
      description: |
        A W3C Verifiable Credential in SD-JWT format proving the
        outcome of a mandate verification. The compact form is the
        authoritative representation; the payload is provided for
        convenience only.
      properties:
        compact:
          type: string
          description: SD-JWT compact serialization (header.payload.signature~disclosure1~...~)
          example: "eyJhbGciOiJQUzI1NiIsInR5cCI6InZjK3NkLWp3dCJ9.eyJ2Y3QiOiJQcm9vZk9mTWFuZGF0ZSJ9.sig~disc1~disc2~"
        payload:
          $ref: "#/components/schemas/ProofOfMandateClaims"

    ProofOfMandateClaims:
      type: object
      description: Decoded PoM VC payload (not authoritative — use compact form for verification)
      properties:
        vct:
          type: string
          enum: [ProofOfMandate]
        decision:
          type: string
          enum: [ALLOW, DENY]
        mandate_id:
          type: string
        verification_event_id:
          type: string
        verification_timestamp:
          type: string
          format: date-time
        requested_action:
          type: string
        requested_resource:
          type: string
        delegate_subject_id:
          type: string
        principal_subject_id:
          type: string
          description: Selectively disclosable
        tenant_id:
          type: string
          description: Selectively disclosable
        reason_codes:
          type: array
          items:
            type: string
          description: Selectively disclosable
        constraints_snapshot:
          type: object
          description: Selectively disclosable
        latency_ms:
          type: number
          description: Selectively disclosable
        instruction_digest:
          type: string
          description: |
            **@experimental** SHA-256 hex digest of the original user instruction.
            Selectively disclosable. Present when the mandate was created with VUI.
          example: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
        iss:
          type: string
          description: Issuer DID
          example: "did:web:api.mandaitor.io"
        sub:
          type: string
          description: Subject (delegate_subject_id)
        iat:
          type: integer
          description: Issued-at (Unix timestamp)
        exp:
          type: integer
          description: Expiration (Unix timestamp)
        _sd_alg:
          type: string
          description: "Selective-disclosure hash algorithm"
          example: "sha-256"

    AuditEvent:
      type: object
      description: An immutable, hash-chained audit event recording a mandate lifecycle action or verification decision.
      properties:
        event_id:
          type: string
        tenant_id:
          type: string
        mandate_id:
          type: string
        event_type:
          type: string
          enum:
            - MANDATE_CREATED
            - MANDATE_SUSPENDED
            - MANDATE_REACTIVATED
            - MANDATE_REVOKED
            - MANDATE_EXPIRED
            - VERIFICATION_ALLOWED
            - VERIFICATION_DENIED
            - ESCALATION_TRIGGERED
            - EVIDENCE_PACK_EXPORTED
            - TENANT_AUDIT_EXPORTED
            - TENANT_ROLE_ASSIGNED
            - TENANT_ROLE_REVOKED
            - EXECUTION_DENIED
            - EXECUTION_APPROVAL_REQUESTED
            - EXECUTION_APPROVAL_SUPERSEDED
            - EXECUTION_APPROVED
            - EXECUTION_AUTHORIZED
            - EXECUTION_DISPATCHED
            - EXECUTION_SUCCEEDED
            - EXECUTION_FAILED
            - EXECUTION_OUTCOME_UNKNOWN
            - EXECUTION_RESOLVED
            - DECISION_ALLOWED
            - DECISION_DENIED
            - DECISION_APPROVAL_REQUIRED
            - DECISION_REDEEMED
            - DECISION_REDEMPTION_REFUSED
        timestamp:
          type: string
          format: date-time
        actor:
          $ref: "#/components/schemas/Subject"
        details:
          type: object
        previous_event_hash:
          type: string
        event_hash:
          type: string
        kms_signature:
          type: string
        hash_algorithm:
          type: string
          description: "Algorithm used to compute event_hash (e.g., SHA_256). Present from crypto-agility v1."
          example: "SHA_256"
        signing_algorithm:
          type: string
          description: "Algorithm used to produce kms_signature (e.g., RSASSA_PSS_SHA_256). Present from crypto-agility v1."
          example: "RSASSA_PSS_SHA_256"

    TenantAuditExportResponse:
      type: object
      description: Tenant-scoped audit export page for SIEM and GRC integrations.
      required:
        - export_id
        - tenant_id
        - generated_at
        - event_count
        - items
      properties:
        export_id:
          type: string
          description: Stable identifier for this generated export page.
          example: audexp_dG50X2FjbWU6MjAyNi0wNS0xOVQxMjozMDowMFowMDBa
        tenant_id:
          type: string
        generated_at:
          type: string
          format: date-time
        event_count:
          type: integer
          minimum: 0
          description: Number of audit events returned in this export page.
        items:
          type: array
          items:
            $ref: "#/components/schemas/AuditEvent"
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for retrieving the next export page.

    AuditEventCloudEvent:
      type: object
      description: |
        A single audit event wrapped in a CNCF CloudEvents 1.0 envelope, as
        emitted by `GET /tenants/{id}/audit-export?format=cloudevents`.

        The CloudEvents specification restricts attribute names to lowercase
        ASCII letters and digits, so Mandaitor's snake_case identifiers are
        renamed in the envelope (`tenant_id` becomes `tenantid`). The untouched
        audit event, including its snake_case fields and hash chain, is carried
        in `data` so that `event_hash` remains verifiable.
      required:
        - specversion
        - id
        - source
        - type
        - tenantid
        - data
      properties:
        specversion:
          type: string
          enum: ["1.0"]
          description: CloudEvents specification version. Identical across a batch.
        id:
          type: string
          description: |
            Event identifier, unique in combination with `source`. Taken from
            `event_id`.
          example: evt_01HZ8K2M4N5P6Q7R8S9T0V
        source:
          type: string
          format: uri-reference
          description: Tenant-scoped audit stream that produced the event.
          example: https://api.mandaitor.io/tenants/tnt_acme/audit-export
        type:
          type: string
          description: |
            Reverse-DNS event type derived from the Mandaitor event type.
            Segments are dot-separated so consumers can apply prefix filters,
            for example subscribing to `io.mandaitor.audit.mandate.` for the
            whole mandate lifecycle.
          example: io.mandaitor.audit.mandate.created
        time:
          type: string
          format: date-time
          description: RFC 3339 timestamp of the occurrence.
        subject:
          type: string
          description: |
            Mandate the event belongs to, allowing a consumer to filter one
            delegation chain. Omitted for events without a mandate.
          example: mdt_a1b2c3d4
        datacontenttype:
          type: string
          enum: ["application/json"]
        dataschema:
          type: string
          format: uri
          description: URI of the schema that `data` conforms to.
        tenantid:
          type: string
          description: "Extension attribute: originating tenant."
        mandateid:
          type: string
          description: "Extension attribute: mandate identifier."
        eventhash:
          type: string
          description: "Extension attribute: hash-chain digest of this event."
        actortype:
          type: string
          description: "Extension attribute: subject type of the acting principal."
          example: AI_AGENT
        data:
          $ref: "#/components/schemas/AuditEvent"

    # ─── Evidence Pack ────────────────────────────────────────

    EvidencePack:
      type: object
      description: |
        Court-ready evidence pack containing all artifacts needed to
        independently verify a mandate decision. Includes hash-chained
        event log, mandate snapshot, proof tokens, export manifest integrity
        metadata, and issuer DID reference.
      properties:
        schema_version:
          type: string
          description: Schema version for forward compatibility
          example: "1.2.0"
        export_timestamp:
          type: string
          format: date-time
        tenant_id:
          type: string
        mandate_id:
          type: string
        mandate_snapshot:
          type: object
          description: Full mandate state at export time
        event_chain:
          type: array
          items:
            $ref: "#/components/schemas/AuditEvent"
          description: Hash-chained audit event log
        event_count:
          type: integer
        case_log_hash:
          type: string
          description: Hash over canonical event chain JSON (algorithm in crypto_metadata)
        chain_integrity:
          type: object
          properties:
            first_event_hash:
              type: string
            last_event_hash:
              type: string
            genesis_verified:
              type: boolean
        issuer:
          type: object
          properties:
            did:
              type: string
              example: "did:web:api.mandaitor.io"
            did_document_url:
              type: string
              format: uri
              example: "https://api.mandaitor.io/.well-known/did.json"
        proof_tokens:
          type: array
          items:
            type: string
          description: SD-JWT compact tokens from verification events
        crypto_metadata:
          type: object
          description: Cryptographic algorithms active at export time
          properties:
            hash_algorithm:
              type: string
              description: "Algorithm used for event chain hashing"
              example: "SHA_256"
            signing_algorithm:
              type: string
              description: "Algorithm used for event signing"
              example: "RSASSA_PSS_SHA_256"
            case_log_hash_algorithm:
              type: string
              description: "Algorithm used for the case_log_hash"
              example: "SHA_256"
        export_manifest:
          type: object
          description: Export integrity manifest with requester attribution, coverage, and recomputable digests.
          required: [export_id, generated_at, generated_by, coverage, digests, hash_algorithm]
          properties:
            export_id:
              type: string
              description: Stable identifier for this evidence-pack export operation.
              example: "mandate_m_123:2026-05-19T12:00:00.000Z"
            generated_at:
              type: string
              format: date-time
            generated_by:
              type: object
              description: Normalized requester attribution derived from the Lambda authorizer RBAC context.
              properties:
                subject:
                  type: string
                tenant_id:
                  type: string
                auth_method:
                  type: string
                roles:
                  type: array
                  items:
                    type: string
                permissions:
                  type: array
                  items:
                    type: string
            coverage:
              type: object
              required: [tenant_id, mandate_id, schema_version, event_count]
              properties:
                tenant_id:
                  type: string
                mandate_id:
                  type: string
                schema_version:
                  type: string
                  example: "1.2.0"
                event_count:
                  type: integer
                event_id_filter:
                  type: string
            digests:
              type: object
              required: [mandate_snapshot_hash, event_chain_hash, proof_tokens_hash, manifest_hash]
              properties:
                mandate_snapshot_hash:
                  type: string
                event_chain_hash:
                  type: string
                proof_tokens_hash:
                  type: string
                manifest_hash:
                  type: string
            hash_algorithm:
              type: string
              example: "SHA_256"
        metadata:
          type: object
          properties:
            mandate_created_at:
              type: string
              format: date-time
            mandate_status:
              type: string
            export_requested_by:
              type: string
            event_id_filter:
              type: string

    # Pagination schema removed — Lambda handlers return flat { items, next_cursor }
    # instead of the nested { mandates/events, pagination: { count, cursor, has_more } } shape.

    # ─── Onboarding Schemas ───────────────────────────────────

    AccessRequest:
      type: object
      description: Request body for submitting a new tenant access request during onboarding.
      required: [company_name, contact_email, use_case]
      properties:
        company_name:
          type: string
          example: "example GmbH"
        contact_name:
          type: string
          example: "Max Mustermann"
        contact_email:
          type: string
          format: email
          example: "max@example.com"
        use_case:
          type: string
          example: "AI agent delegation for construction validation"
        industry:
          type: string
          enum: [construction, real_estate, venture_capital, other]
        website:
          type: string
          format: uri
        lead_source:
          type: string
          description: Marketing or referral source associated with the beta request.
          example: "trust-site"
        beta_track:
          type: string
          description: Closed-beta routing segment used for the request.
          example: "construction"
        asset:
          type: string
          description: Specific public asset or CTA entry point that produced the request.
          example: "home-hero"

    # AccessRequestRecord removed — was unused (redocly no-unused-components).
    # Re-add when /admin/onboarding list endpoint is implemented.

    TenantRecord:
      type: object
      description: A tenant record containing the tenant's identity, plan, status, and API key metadata.
      properties:
        tenant_id:
          type: string
        company_name:
          type: string
        plan:
          type: string
        status:
          type: string
          enum: [ACTIVE, SUSPENDED, DEACTIVATED]
        created_at:
          type: string
          format: date-time
        api_keys:
          type: array
          items:
            type: object
            properties:
              key_id:
                type: string
              name:
                type: string
              scopes:
                type: array
                items:
                  type: string
              created_at:
                type: string
                format: date-time

    # ─── Widget Config Schemas ────────────────────────────────

    WidgetConfig:
      type: object
      description: Full widget configuration for a tenant, including identity providers, taxonomy libraries, branding, approval workflows, and webhook settings.
      properties:
        tenant_id:
          type: string
        widget_id:
          type: string
        identity_providers:
          type: array
          items:
            type: object
            properties:
              provider:
                type: string
                enum: [eidas_eudi, entra_id, auth0, okta]
              enabled:
                type: boolean
              config:
                type: object
        taxonomy_libraries:
          type: array
          items:
            type: string
          example: ["@mandaitor/taxonomy-construction"]
        mandate_templates:
          type: array
          items:
            type: object
        branding:
          type: object
          properties:
            primary_color:
              type: string
            logo_url:
              type: string
            company_name:
              type: string
        approval_workflow:
          type: object
          properties:
            require_principal_approval:
              type: boolean
            require_admin_approval:
              type: boolean
            auto_approve_trusted_idps:
              type: boolean
        webhook_url:
          type: string
          format: uri
        updated_at:
          type: string
          format: date-time

    WidgetConfigUpdate:
      type: object
      description: Request body for updating a tenant's widget configuration. All fields are optional; only provided fields are updated.
      properties:
        identity_providers:
          type: array
          items:
            type: object
        taxonomy_libraries:
          type: array
          items:
            type: string
        mandate_templates:
          type: array
          items:
            type: object
        branding:
          type: object
        approval_workflow:
          type: object
        webhook_url:
          type: string
          format: uri

    # ─── Error Schema ─────────────────────────────────────────

    # ─── Identity Provider Configuration Schemas ───────────────

    ConnectorCatalogEntry:
      type: object
      properties:
        type:
          type: string
          description: Stable connector type id
          example: servicenow
        display_name:
          type: string
        vendor:
          type: string
        description:
          type: string
        maturity:
          type: string
          enum: [pilot]
        configurable:
          type: boolean
          description: Whether tenants can configure instances of this connector today
        tools:
          type: array
          items:
            type: object
            properties:
              tool:
                type: string
                example: servicenow.requested_item.approve
              effect:
                type: string
                enum: [read, write, financial, execute]
              description:
                type: string
        config_fields:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              label:
                type: string
              type:
                type: string
                enum: [string, password, url]
              required:
                type: boolean
              secret:
                type: boolean
                description: Secret fields are write-only — never returned after creation
              placeholder:
                type: string

    Environment:
      type: string
      pattern: "^[a-z][a-z0-9-]{0,31}$"
      maxLength: 32
      description: |
        An environment (execution roadmap E2.10): a landscape the tenant's
        systems run in, such as a quality system and a production one. An
        agent is registered in one and a connector created in one, and a tool
        is enabled per environment on a connector of that environment, so an
        agent never reaches another environment's systems. Anything not given
        one is in `default`.
      example: qas

    CreateTenantConnector:
      type: object
      required: [type, name, config]
      properties:
        type:
          type: string
          description: Catalog entry to configure (must be configurable)
          example: servicenow
        name:
          type: string
          maxLength: 100
          example: ServiceNow PDI (dev209313)
        config:
          type: object
          additionalProperties:
            type: string
          description: |
            Field values keyed by the catalog entry's config_fields names.
            Secret fields (e.g. password) are KMS-encrypted at rest and never
            returned by the API.
          example:
            instance_url: https://dev209313.service-now.com
            username: mandaitor.integration
            password: "…"
        environment:
          allOf:
            - $ref: "#/components/schemas/Environment"
          description: |
            The environment the system belongs to, `default` when not given.
            Fixed at creation.

    RotateConnectorCredentials:
      type: object
      additionalProperties: false
      required: [credentials]
      description: |
        A connector's new credential: every secret field of its catalog entry
        (`password` for ServiceNow, `api_key` for SAP S/4HANA) and nothing
        else. Its other fields, the URL and user name among them, stay as
        they are: a credential for another system or account is another
        connector.
      properties:
        credentials:
          type: object
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 512
          example:
            password: "…"

    TenantConnector:
      type: object
      properties:
        connector_id:
          type: string
          example: con_01JX5T9K2M
        tenant_id:
          type: string
        type:
          type: string
          example: servicenow
        name:
          type: string
        config:
          type: object
          additionalProperties:
            type: string
          description: Non-secret configuration fields only
        sandbox_id:
          type: string
          description: |
            Present when this connector points at a Mandaitor-hosted demo
            sandbox rather than a customer system. Only these can be reset.
          example: sbx_01JX5T9K2M
        environment:
          allOf:
            - $ref: "#/components/schemas/Environment"
          description: |
            The environment the system belongs to, fixed at creation;
            `default` for connectors created before environments existed.
        credentials_rotated_at:
          type: string
          format: date-time
          description: When the credential was last replaced. Absent until it first is.
        credentials_rotated_by:
          type: string
          maxLength: 256
          description: Who last replaced the credential.
        status:
          type: string
          enum: [unverified, connected, error]
        last_test_at:
          type: string
          format: date-time
        last_test_message:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TaxonomyView:
      type: object
      description: A loaded taxonomy's business actions and resource patterns.
      required: [id, version, name, description, tags, actions, resource_patterns]
      properties:
        id:
          type: string
          maxLength: 64
          example: construction
        package:
          type: string
          maxLength: 128
          description: The npm package the taxonomy comes from.
          example: "@mandaitor/taxonomy-construction"
        version:
          type: string
          maxLength: 32
        name:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 2000
        tags:
          type: array
          items:
            type: string
            maxLength: 64
        actions:
          type: array
          items:
            type: object
            required: [id, label, description, risk_level, requires_human_approval, tags]
            properties:
              id:
                type: string
                maxLength: 200
              label:
                type: string
                maxLength: 200
              description:
                type: string
                maxLength: 2000
              risk_level:
                type: string
                enum: [LOW, MEDIUM, HIGH, CRITICAL]
              requires_human_approval:
                type: boolean
              tags:
                type: array
                items:
                  type: string
                  maxLength: 64
              parent_action:
                type: string
                maxLength: 200
        resource_patterns:
          type: array
          items:
            type: object
            required: [name, pattern, description]
            properties:
              name:
                type: string
                maxLength: 64
              pattern:
                type: string
                maxLength: 500
              description:
                type: string
                maxLength: 2000

    ToolDefinition:
      type: object
      description: |
        One version of a tool: one operation on one kind of system, mapped to
        one business action (docs/execution/tools-and-adapters.md). Every
        placeholder of `resource_pattern` is filled from a fact the adapter
        reads from the target, never from the arguments.
      required:
        - id
        - version
        - title
        - business_action
        - resource_pattern
        - connector_type
        - adapter
        - effect_class
        - identity_modes
        - arguments_schema
        - required_facts
        - preconditions
        - returnable_result
        - max_approval_window_seconds
        - available
      properties:
        id:
          type: string
          maxLength: 128
          example: sap.purchase_requisition.release
        version:
          type: integer
          minimum: 1
        title:
          type: string
          maxLength: 200
        business_action:
          type: string
          maxLength: 200
          example: procurement.requisition.release
        resource_pattern:
          type: string
          maxLength: 500
          example: "procurement:plant:{plant}/requisition:{purchase_requisition}"
        connector_type:
          type: string
          maxLength: 64
          example: sap-s4hana
        adapter:
          type: string
          maxLength: 128
        effect_class:
          type: string
          enum: [read, write_idempotent, write_non_idempotent]
        identity_modes:
          type: array
          items:
            type: string
            enum: [service_account]
        arguments_schema:
          type: object
          additionalProperties: true
          description: |
            JSON Schema (2020-12 subset) for the request's `arguments`:
            identifiers only, and unknown keys are rejected.
        required_facts:
          type: object
          description: Fact name to fact type (`money`, `string`, `enum` with `values`, `integer`, `boolean`, `timestamp`).
          additionalProperties:
            type: object
            required: [type]
            properties:
              type:
                type: string
                enum: [money, string, enum, integer, boolean, timestamp]
              values:
                type: array
                items:
                  type: string
                  maxLength: 128
        preconditions:
          type: array
          items:
            type: object
            required: [fact, equals]
            properties:
              fact:
                type: string
                maxLength: 64
              equals:
                oneOf:
                  - type: string
                    maxLength: 128
                  - type: number
                  - type: boolean
        returnable_result:
          type: array
          items:
            type: string
            maxLength: 64
        max_approval_window_seconds:
          type: integer
          minimum: 60
          maximum: 86400
        available:
          type: boolean
          description: Whether a loaded taxonomy defines `business_action`.
        unavailable_reason:
          type: string
          enum: [BUSINESS_ACTION_UNKNOWN]

    ToolEnablement:
      type: object
      required: [tool_id, version, environment, connector_id, enabled_at, enabled_by]
      properties:
        tool_id:
          type: string
          maxLength: 128
        version:
          type: integer
          minimum: 1
        environment:
          allOf:
            - $ref: "#/components/schemas/Environment"
          description: The environment it is enabled in; its connector is in the same one.
        connector_id:
          type: string
          maxLength: 68
        enabled_at:
          type: string
          format: date-time
        enabled_by:
          type: string
          maxLength: 256

    ExecutionPolicy:
      type: object
      required: [policy_id, version, tools]
      properties:
        policy_id:
          type: string
          nullable: true
          maxLength: 64
          example: tpol_01J9EXAMPLE
        version:
          type: integer
          minimum: 0
        updated_at:
          type: string
          format: date-time
        updated_by:
          type: string
          maxLength: 256
        tools:
          type: array
          items:
            $ref: "#/components/schemas/ToolEnablement"

    EnableToolRequest:
      type: object
      additionalProperties: false
      required: [version, connector_id]
      properties:
        version:
          type: integer
          minimum: 1
        connector_id:
          type: string
          pattern: "^con_[A-Za-z0-9]{1,64}$"
          maxLength: 68
        environment:
          allOf:
            - $ref: "#/components/schemas/Environment"
          description: |
            The environment to enable it in, `default` when not given. The
            connector must be in it.

    DecisionMoney:
      type: object
      description: An amount, as the constraint vocabulary reads it. Never a JSON number.
      additionalProperties: false
      required: [currency, value]
      properties:
        currency:
          type: string
          maxLength: 3
          pattern: "^[A-Z]{3}$"
          description: ISO 4217
          example: EUR
        value:
          type: string
          maxLength: 37
          pattern: "^(0|[1-9][0-9]{0,17})(\\.[0-9]{1,18})?$"
          description: A non-negative decimal string.
          example: "1250.00"

    DecisionRequest:
      type: object
      description: "`mandaitor.decision-request/v1`: one operation an enforcement point is about to perform for an agent."
      additionalProperties: false
      required: [contract, agent, action, resource, facts, audience]
      properties:
        contract:
          type: string
          maxLength: 64
          enum: [mandaitor.decision-request/v1]
        agent:
          type: string
          maxLength: 256
          pattern: "^did:[a-z0-9]+:[\\x21-\\x7E]+$"
          description: The agent you act for, by its DID.
          example: did:web:agents.mandaitor.io:agt_01JZ8Y3S6N0F4K7Q2W9E5R1T8V
        action:
          type: string
          maxLength: 128
          pattern: "^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$"
          description: A business action a taxonomy defines. Unknown actions are denied.
          example: procurement.requisition.release
        resource:
          type: string
          maxLength: 512
          pattern: "^[A-Za-z0-9._~-]+(:[A-Za-z0-9._~-]+)*(/[A-Za-z0-9._~-]+(:[A-Za-z0-9._~-]+)*)*$"
          description: |
            The one record the operation acts on, in the taxonomies' syntax. No
            `*`, no `{placeholder}`: a request never names more than one record.
          example: procurement:plant:1000/requisition:10004711
        facts:
          type: object
          maxProperties: 32
          description: |
            What you read from your own system of record, never from the
            agent. Names are 1 to 64 lower-case letters, digits and `_`,
            starting with a letter. Each value is
            a string (at most 256 characters), a safe integer, a boolean, or
            money; `amount`, when given, is money. Every fact is bound into
            the request digest.
          additionalProperties:
            oneOf:
              - type: string
                maxLength: 256
              - type: integer
              - type: boolean
              - $ref: "#/components/schemas/DecisionMoney"
          example:
            plant: "1000"
            amount:
              currency: EUR
              value: "1250.00"
        audience:
          type: string
          minLength: 1
          maxLength: 256
          pattern: "^[\\x21-\\x7E]{1,256}$"
          description: Your enforcement point, as it names itself. The authorization is for it alone.
          example: https://erp.example.com
        mandate_id:
          type: string
          pattern: "^mdt_[A-Za-z0-9]{1,64}$"
          maxLength: 68
          description: The mandate you believe covers the operation. A hint only.
        purpose:
          type: string
          maxLength: 500
          description: Recorded with the decision. Never used to decide.

    Decision:
      type: object
      description: "`mandaitor.decision/v1`: Mandaitor's answer. Only an `ALLOW` carries an authorization."
      required: [contract, decision, code, reason, request_digest, event_id, decided_at]
      properties:
        contract:
          type: string
          maxLength: 64
          enum: [mandaitor.decision/v1]
        decision:
          type: string
          maxLength: 16
          enum: [ALLOW, DENY, REQUIRE_APPROVAL]
        code:
          type: string
          maxLength: 64
          description: |
            `ALLOWED`; `APPROVAL_REQUIRED`; or why it denies:
            `UNKNOWN_ACTION`, `AGENT_UNKNOWN`, `AGENT_NOT_ACTIVE`,
            `TOO_MANY_MANDATES`, `NO_COVERING_MANDATE`, `HINT_NOT_COVERING`,
            `DENIED_BY_MANDATE`, `FACT_UNAVAILABLE`, `INVALID_FACT_VALUE`,
            `UNSUPPORTED_CONSTRAINT`, `MANDATE_EXPIRED`,
            `MANDATE_NOT_YET_VALID`, `CURRENCY_MISMATCH`, `AMOUNT_ABOVE_LIMIT`,
            `CUMULATIVE_LIMIT_EXCEEDED`, `MANDATE_NOT_ACTIVE`,
            `MANDATE_SIGNATURE_INVALID`, or `KILL_SWITCH` (the tenant's
            kill switch is running).
          example: ALLOWED
        reason:
          type: string
          maxLength: 1000
        request_digest:
          type: string
          maxLength: 51
          pattern: "^sha-256:[A-Za-z0-9_-]{43}$"
          description: The request digest, as Mandaitor computed it.
        event_id:
          type: string
          maxLength: 64
          description: The decision's event on the audit chain.
        decided_at:
          type: string
          maxLength: 32
          format: date-time
        mandate_id:
          type: string
          maxLength: 68
          description: The mandate the decision rests on, or the one a denial is reported against.
        fact:
          type: string
          maxLength: 64
          description: For a missing or unusable fact, its name.
        constraints:
          type: array
          description: For `UNSUPPORTED_CONSTRAINT`, every key the decision cannot enforce.
          items:
            type: string
        approval_rules:
          type: array
          description: In `REQUIRE_APPROVAL`, why a person would have to approve. No approval is raised.
          items:
            type: string
            enum: [AMOUNT_ABOVE_THRESHOLD, REQUIRE_MFA, ACTION_REQUIRES_APPROVAL]
        decision_id:
          type: string
          maxLength: 30
          pattern: "^dec_[0-9A-Z]{26}$"
          description: In `ALLOW`, the id to redeem the authorization by; its `jti`.
        authorization:
          type: string
          maxLength: 4096
          description: |
            In `ALLOW`, the signed authorization (`mandaitor.decision-authorization/v1`),
            a compact JWS. Its claims: `iss`, `aud` (your `audience`), `sub`
            (the agent's DID), `iat`, `exp`, `jti`, `contract`, `tenant_id`,
            `request_digest`, `action`, `resource`, `mandate_id`, `event_id`.
        expires_at:
          type: string
          maxLength: 32
          format: date-time
          description: In `ALLOW`, the authorization's `exp`.

    DecisionRedemptionRequest:
      type: object
      additionalProperties: false
      required: [request_digest]
      properties:
        request_digest:
          type: string
          maxLength: 51
          pattern: "^sha-256:[A-Za-z0-9_-]{43}$"
          description: The digest of the operation you are about to perform, computed by you.

    DecisionRedemption:
      type: object
      required: [decision_id, redeemed_at, event_id]
      properties:
        decision_id:
          type: string
          maxLength: 30
        redeemed_at:
          type: string
          maxLength: 32
          format: date-time
        event_id:
          type: string
          maxLength: 64
          description: The redemption's event on the audit chain.

    DecisionError:
      type: object
      description: An error from the decision API. Act on `error`.
      required: [error, message]
      properties:
        error:
          type: string
          maxLength: 64
          example: ALREADY_REDEEMED
        message:
          type: string
          maxLength: 1000
        errors:
          type: array
          description: Each problem, for `INVALID_REQUEST`.
          items:
            type: object
            required: [path, message, code]
            properties:
              path:
                type: string
                maxLength: 256
              message:
                type: string
                maxLength: 1000
              code:
                type: string
                maxLength: 64
        event_id:
          type: string
          maxLength: 64
          description: For a refused redemption, the refusal's event on the audit chain.

    ToolRequest:
      type: object
      description: "`mandaitor.tool-request/v1`: one typed business operation an agent asks for."
      additionalProperties: false
      required: [contract, tool, arguments, idempotency_key]
      properties:
        contract:
          type: string
          enum: [mandaitor.tool-request/v1]
        tool:
          type: string
          minLength: 1
          maxLength: 128
          example: sap.purchase_requisition.release
        tool_version:
          type: integer
          minimum: 1
          description: Pins a definition version. It must be the version the tenant enabled.
        arguments:
          type: object
          additionalProperties: true
          description: |
            Identifiers only, checked against the tool's `arguments_schema`
            (`GET /tools`). Unknown keys are refused.
          example:
            purchase_requisition: "10000124"
        idempotency_key:
          type: string
          minLength: 8
          maxLength: 128
          pattern: "^[\\x21-\\x7E]{8,128}$"
          description: 8 to 128 visible ASCII characters, chosen by the agent.
          example: 6f1c2c8e-3b0e-4d5e-9d61-1d4a8f0b7c21
        mandate_id:
          type: string
          pattern: "^mdt_[A-Za-z0-9]{1,64}$"
          maxLength: 68
          description: |
            The mandate the agent believes covers the call. A hint only: the
            decision considers every mandate that names the agent.
        purpose:
          type: string
          maxLength: 500
          description: Shown to an approver. Never used in the decision.

    Execution:
      type: object
      description: "`mandaitor.execution/v1`: one requested operation and what became of it."
      required:
        - contract
        - execution_id
        - state
        - state_reason
        - tool
        - request_digest
        - decision
        - approval
        - result
        - receipt
        - created_at
        - updated_at
      properties:
        contract:
          type: string
          enum: [mandaitor.execution/v1]
        execution_id:
          type: string
          pattern: "^exe_[0-9A-Z]{26}$"
          maxLength: 30
        state:
          type: string
          enum:
            - DENIED
            - AWAITING_APPROVAL
            - AUTHORIZED_NOT_EXECUTED
            - EXECUTING
            - SUCCEEDED
            - FAILED
            - UNKNOWN
          description: "`DENIED`, `SUCCEEDED` and `FAILED` are final."
        state_reason:
          type: string
          maxLength: 64
          description: |
            Qualifies the state. The full list, by state (roadmap E1.8). A
            feature that adds a reason adds it here, as the kill switch did
            (E2.6); treat a reason you do not know as its state's general
            meaning.

            `DENIED`, before the record is read: `NO_COVERING_MANDATE`,
            `HINT_NOT_COVERING`, `TOO_MANY_MANDATES`; `ADAPTER_UNAVAILABLE`
            (no adapter runs the tool); `CONNECTOR_NOT_PERMITTED` (Phase 1
            runs against Mandaitor's hosted sandboxes only);
            `CONNECTOR_UNUSABLE` (the connector is gone or its credential
            cannot be read; delete it and add it again). On the record:
            `RECORD_NOT_FOUND`, `TARGET_REFUSED`, `DENIED_BY_MANDATE`,
            `FACT_UNAVAILABLE`, `INVALID_FACT_VALUE`, `PRECONDITION_FAILED`,
            `UNSUPPORTED_CONSTRAINT`, `MANDATE_EXPIRED`,
            `MANDATE_NOT_YET_VALID`, `CURRENCY_MISMATCH`,
            `AMOUNT_ABOVE_LIMIT`, `CUMULATIVE_LIMIT_EXCEEDED` (the
            mandate's count or total for the hour, day, week or month has
            no room left for this execution; also when another execution
            took the room between the decision and the write), and
            `APPROVAL_LIMIT_REACHED` (the agent has raised as many approval
            requests today as it may). An
            approval's end: `APPROVAL_REJECTED`, `APPROVAL_EXPIRED`,
            `RECORD_CHANGED` (the record changed while it waited, so that it
            needs no approval; ask again), `AGENT_NOT_ACTIVE`,
            `MANDATE_REVOKED`, `MANDATE_SUSPENDED`. `TOOL_NOT_ENABLED`: the
            tool was disabled, or moved to another connector, after the
            decision; also when it was disabled between the recheck and the
            dispatch. `KILL_SWITCH`: the tenant's kill switch stopped the
            execution while it waited for an approval or for its dispatch.
            At the recheck before the write, any of these, or
            `TARGET_UNAVAILABLE`, `MANDATE_NOT_ACTIVE`,
            `MANDATE_SIGNATURE_INVALID`, `DECISION_EXPIRED`, or
            `AGENT_NOT_ACTIVE` (the agent was suspended or deleted after
            its request was decided). `APPROVAL_UNAVAILABLE` was recorded
            before approvals existed.

            `AWAITING_APPROVAL`: `APPROVAL_REQUIRED`, or
            `APPROVAL_SUPERSEDED` once what an approval covered changed and a
            new one was raised. `AUTHORIZED_NOT_EXECUTED`: `AUTHORIZED`, or
            `APPROVED`. `EXECUTING`: `DISPATCHED`. `SUCCEEDED`: `APPLIED`,
            `RESOLVED_BY_READ_BACK`, or `RESOLVED_BY_OPERATOR` (a person of
            the tenant recorded the outcome no read-back could establish;
            there is no receipt). `FAILED`: `VERSION_CHANGED` (the record
            changed after it was read), `TARGET_REFUSED`, `NOT_ATTEMPTED`,
            `RESOLVED_BY_READ_BACK`, or `RESOLVED_BY_OPERATOR`. `UNKNOWN`:
            `TIMEOUT_AFTER_DISPATCH`, `INDETERMINATE`,
            `READ_BACK_INCONCLUSIVE`, or `DISPATCH_INTERRUPTED` (Mandaitor
            found the execution dispatched with no outcome recorded; a
            read-back follows).
          enum:
            - NO_COVERING_MANDATE
            - HINT_NOT_COVERING
            - TOO_MANY_MANDATES
            - ADAPTER_UNAVAILABLE
            - CONNECTOR_NOT_PERMITTED
            - CONNECTOR_UNUSABLE
            - RECORD_NOT_FOUND
            - TARGET_REFUSED
            - DENIED_BY_MANDATE
            - FACT_UNAVAILABLE
            - INVALID_FACT_VALUE
            - PRECONDITION_FAILED
            - UNSUPPORTED_CONSTRAINT
            - MANDATE_EXPIRED
            - MANDATE_NOT_YET_VALID
            - CURRENCY_MISMATCH
            - AMOUNT_ABOVE_LIMIT
            - CUMULATIVE_LIMIT_EXCEEDED
            - APPROVAL_LIMIT_REACHED
            - APPROVAL_REJECTED
            - APPROVAL_EXPIRED
            - RECORD_CHANGED
            - AGENT_NOT_ACTIVE
            - MANDATE_REVOKED
            - MANDATE_SUSPENDED
            - TOOL_NOT_ENABLED
            - KILL_SWITCH
            - TARGET_UNAVAILABLE
            - MANDATE_NOT_ACTIVE
            - MANDATE_SIGNATURE_INVALID
            - DECISION_EXPIRED
            - APPROVAL_UNAVAILABLE
            - APPROVAL_REQUIRED
            - APPROVAL_SUPERSEDED
            - AUTHORIZED
            - APPROVED
            - DISPATCHED
            - APPLIED
            - RESOLVED_BY_READ_BACK
            - RESOLVED_BY_OPERATOR
            - VERSION_CHANGED
            - NOT_ATTEMPTED
            - TIMEOUT_AFTER_DISPATCH
            - INDETERMINATE
            - READ_BACK_INCONCLUSIVE
            - DISPATCH_INTERRUPTED
          example: NO_COVERING_MANDATE
        tool:
          type: object
          required: [id, version]
          properties:
            id:
              type: string
              maxLength: 128
            version:
              type: integer
              minimum: 1
        request_digest:
          type: string
          pattern: "^sha-256:[A-Za-z0-9_-]{43}$"
          maxLength: 51
          description: |
            SHA-256 over the RFC 8785 serialisation of the contract, tenant,
            agent, tool version and normalised arguments. Two requests with the
            same digest are the same request.
        decision:
          type: object
          description: |
            The decision the execution rests on: the one made on the request,
            or the recheck's refusal when the recheck before the write denied
            it.
          required: [event_id, outcome, reason]
          properties:
            event_id:
              type: string
              maxLength: 64
              description: The audit event that recorded the decision.
            outcome:
              type: string
              enum: [ALLOW, DENY, REQUIRE_APPROVAL]
            reason:
              type: string
              maxLength: 1000
            mandate_id:
              type: string
              maxLength: 68
              description: |
                The mandate the decision rests on, or the one a denial is
                reported against: a `mandate_id` hint that does not cover the
                call, or a `DENY` mandate that does.
        approval:
          type: object
          nullable: true
          description: |
            The approval the decision required: in `AWAITING_APPROVAL`, the
            one it waits for. `null` when none was required.
          required: [consent_id, digest, expires_at]
          properties:
            consent_id:
              type: string
              maxLength: 68
              description: The consent that carries it into the approver's inbox.
            digest:
              type: string
              pattern: "^sha-256:[A-Za-z0-9_-]{43}$"
              maxLength: 51
              description: |
                The approval digest: SHA-256 over the RFC 8785 serialisation
                of everything the decision rested on (the record's facts and
                version, the resource, the mandates, the policy and the
                expiry). The approver sends it back to approve.
            expires_at:
              type: string
              format: date-time
              description: The last moment the approval may lead to a write.
            approved_at:
              type: string
              format: date-time
              description: Once it was approved.
        result:
          type: object
          nullable: true
          description: |
            In `SUCCEEDED`: the record as read back after the write, limited to
            the fields the tool declares returnable. For both
            `sap.purchase_requisition.release` and
            `servicenow.requested_item.approve`, `purchase_requisition` and
            `release_state`. `null` in every other state.
        receipt:
          type: string
          nullable: true
          maxLength: 16384
          description: |
            The signed execution receipt: the proof of the outcome, as a PoM is
            the proof of an authorization. Present in `SUCCEEDED`, and in
            `FAILED` once a read-back found the write absent; null otherwise,
            including for `NOT_ATTEMPTED`, where the target was never reached.

            A JWS in compact serialisation, `alg` `PS256`, `typ`
            `mandaitor-receipt+jwt`, signed with a key of its own that the
            issuer's DID document (`/.well-known/did.json`) publishes as
            `#receipt-key-1`. Its claims: `iss`, `iat`, `jti`, `sub` (the
            agent's DID), `contract` (`mandaitor.execution-receipt/v1`),
            `tenant_id`, `execution_id`, `state` (`SUCCEEDED` or `FAILED`),
            `tool`, `request_digest`, `approval` (`consent_id`, `digest`,
            `decided_by`, `decided_at`, when a person approved it),
            `authorization` (`event_id`, `mandate_id` and `pom_digest`, the
            SHA-256 of the PoM the dispatch was authorised by), `target`
            (`connector_id`, `system`, `record`, `version_before`,
            `version_after`, and `transaction_ref` where the target has one),
            `timeline` (`decided_at`, `dispatched_at`, `completed_at`) and, in
            `SUCCEEDED`, `result_digest`: the SHA-256 of the JCS form of
            `result`, so the receipt binds what the agent was told.

            A token is a receipt only when its `typ`, its `contract` and its
            key all say so: a PoM, or anything signed with the PoM key
            (`#key-1`), is not one. `@mandaitor/sdk` checks all of this, and
            the receipt against the execution it came with, in
            `verifyReceiptForExecution`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TenantExecution:
      description: |
        An execution as a person of the tenant reads it (roadmap E1.11): the
        execution its agent reads, and what it rested on. Never the agent's
        session or its idempotency key.
      allOf:
        - $ref: "#/components/schemas/Execution"
        - type: object
          required:
            - agent
            - connector_id
            - arguments
            - purpose
            - scope
            - facts
            - target
            - authorization
            - outcome
            - reconciliation
            - resolution
            - dispatched_at
            - completed_at
          properties:
            agent:
              type: object
              required: [agent_id, did]
              properties:
                agent_id:
                  type: string
                  maxLength: 64
                did:
                  type: string
                  maxLength: 256
            connector_id:
              type: string
              maxLength: 64
              description: The connector the tool runs on.
            arguments:
              type: object
              description: The request's arguments, normalised. Identifiers only.
            purpose:
              type: string
              nullable: true
              maxLength: 500
              description: Why the agent asked, as it said. Never used to decide.
            decision:
              type: object
              properties:
                decided_at:
                  type: string
                  format: date-time
            scope:
              type: object
              nullable: true
              description: |
                The business action and the resource built from the record's
                facts, once the record was read.
              required: [action, resource]
              properties:
                action:
                  type: string
                  maxLength: 256
                resource:
                  type: string
                  maxLength: 1024
            facts:
              type: object
              nullable: true
              description: |
                The facts the tool declares, as read from the record, such as
                `amount` and `plant`. The decision rested on these, never on
                what the agent said.
            target:
              type: object
              nullable: true
              description: The record that was read, and the version that was decided on.
              required: [connector_id, record, version, version_kind]
              properties:
                connector_id:
                  type: string
                  maxLength: 64
                record:
                  type: string
                  maxLength: 512
                version:
                  type: string
                  maxLength: 256
                version_kind:
                  type: string
                  maxLength: 32
            approval:
              type: object
              nullable: true
              description: |
                Besides what the agent sees: who must approve, whether MFA is
                required, the rules that asked for it, and who approved.
              properties:
                approver:
                  type: string
                  maxLength: 256
                  description: "`escalation_rules.escalate_to`, or else the mandate's principal."
                require_mfa:
                  type: boolean
                rules:
                  type: array
                  items:
                    type: string
                    enum: [AMOUNT_ABOVE_THRESHOLD, REQUIRE_MFA, ACTION_REQUIRES_APPROVAL]
                approved_by:
                  type: string
                  maxLength: 256
            authorization:
              type: object
              nullable: true
              description: |
                What the write was dispatched on: the dispatch's audit event,
                the mandate, and the SHA-256 of the PoM. A receipt names the
                same.
              required: [event_id, mandate_id, pom_digest]
              properties:
                event_id:
                  type: string
                  maxLength: 64
                mandate_id:
                  type: string
                  maxLength: 68
                pom_digest:
                  type: string
                  pattern: "^sha-256:[A-Za-z0-9_-]{43}$"
                  maxLength: 51
            outcome:
              type: object
              nullable: true
              description: What the target said about the write. Never a body.
              properties:
                version_after:
                  type: string
                  maxLength: 256
                transaction_ref:
                  type: string
                  maxLength: 256
                target_status:
                  type: integer
                reason:
                  type: string
                  maxLength: 1000
                write_acknowledged:
                  type: boolean
                  description: |
                    In `UNKNOWN`: the target answered the write as applied,
                    but the read-back did not confirm it. A later read-back
                    that does not find the write then proves nothing, so it
                    never makes the execution `FAILED`.
            reconciliation:
              type: object
              nullable: true
              description: |
                The read-backs made after the first while it was `UNKNOWN`,
                why the last settled nothing, and when the next is due. Null
                until one settled nothing; kept once the execution is
                resolved. `next_at` is null once they are spent: the
                execution then waits for a person of the tenant to resolve it.
              required: [attempts, last_attempt_at, last_reason, next_at]
              properties:
                attempts:
                  type: integer
                  minimum: 0
                last_attempt_at:
                  type: string
                  format: date-time
                last_reason:
                  type: string
                  maxLength: 256
                  example: READ_UNAVAILABLE
                next_at:
                  type: string
                  format: date-time
                  nullable: true
            resolution:
              type: object
              nullable: true
              description: |
                Once a person of the tenant resolved it from `UNKNOWN`
                (`RESOLVED_BY_OPERATOR`): who, when, and the evidence they
                recorded. Mandaitor records their evidence; it does not
                verify it.
              required: [resolved_by, resolved_at, evidence]
              properties:
                resolved_by:
                  type: string
                  maxLength: 256
                resolved_at:
                  type: string
                  format: date-time
                evidence:
                  type: string
                  maxLength: 2000
                transaction_ref:
                  type: string
                  maxLength: 256
            reservation:
              type: object
              nullable: true
              description: |
                What the execution reserved against its mandate's cumulative
                limits (`max_count`, `max_total`) before its write.
                `HELD` while the write is in flight or its outcome is
                `UNKNOWN`; `CONSUMED` once it `SUCCEEDED`; `RELEASED` once the
                write is proved absent or was never made, when the limits got
                it back. Null when the mandate states no cumulative limit.
              required: [reservation_id, mandate_id, status, held_at, counters]
              properties:
                reservation_id:
                  type: string
                mandate_id:
                  type: string
                status:
                  type: string
                  enum: [HELD, CONSUMED, RELEASED]
                held_at:
                  type: string
                  format: date-time
                settled_at:
                  type: string
                  format: date-time
                amount:
                  type: object
                  description: The amount the totals counted.
                  properties:
                    currency:
                      type: string
                    value:
                      type: string
                counters:
                  type: array
                  items:
                    type: object
                    required: [kind, window, window_start, window_end, limit, delta]
                    properties:
                      kind:
                        type: string
                        enum: [count, total]
                      window:
                        $ref: "#/components/schemas/LimitWindow"
                      window_start:
                        type: string
                        format: date-time
                      window_end:
                        type: string
                        format: date-time
                      limit:
                        type: string
                        description: The most the window may hold, as a decimal string.
                      delta:
                        type: string
                        description: What this execution added, as a decimal string.
            dispatched_at:
              type: string
              format: date-time
              nullable: true
            completed_at:
              type: string
              format: date-time
              nullable: true

    ExecutionError:
      type: object
      description: An error from the execution API. Act on `error`.
      required: [error, message]
      properties:
        error:
          type: string
          maxLength: 64
          example: TOOL_NOT_ENABLED
        message:
          type: string
          maxLength: 1000
        errors:
          type: array
          description: Each problem, for `INVALID_REQUEST` and `INVALID_ARGUMENTS`.
          items:
            type: object
            required: [path, message, code]
            properties:
              path:
                type: string
                maxLength: 256
              message:
                type: string
                maxLength: 1000
              code:
                type: string
                maxLength: 64
        execution_id:
          type: string
          maxLength: 30
          description: For `IDEMPOTENCY_IN_PROGRESS`, the execution the key names.
        execution:
          description: |
            For a 409 on an approval, the execution as it is now. For a 409
            on a resolution, the execution as a person of the tenant reads it.
          allOf:
            - $ref: "#/components/schemas/Execution"

    ExecutionApprovalDecision:
      type: object
      description: A person's decision on an execution's approval.
      required: [decision]
      additionalProperties: false
      properties:
        decision:
          type: string
          enum: [APPROVE, REJECT]
        approval_digest:
          type: string
          pattern: "^sha-256:[A-Za-z0-9_-]{43}$"
          description: Required to approve. The digest you were shown.
        consent_id:
          type: string
          description: The approval you were shown. One a newer approval replaced is refused.
        comment:
          type: string
          maxLength: 1000

    LimitWindow:
      type: string
      enum: [PT1H, P1D, P1W, P1M]
      description: |
        A cumulative limit's window: a calendar window in UTC. `PT1H` is a
        clock hour, `P1D` a day from 00:00 UTC, `P1W` an ISO week from Monday
        00:00 UTC, `P1M` a calendar month. Each window starts from zero, so a
        limit of 10 a day allows 10 at 23:59 UTC and 10 more at 00:01.

    ExecutionResolution:
      type: object
      description: |
        A person's account of an `UNKNOWN` execution's outcome, after
        looking at the target system: what they found, and how they know.
      required: [outcome, evidence]
      additionalProperties: false
      properties:
        outcome:
          type: string
          enum: [SUCCEEDED, FAILED]
          description: |
            `SUCCEEDED` if the write took effect in the target system,
            `FAILED` if it did not. `FAILED` tells the agent it may ask again,
            which writes again: be sure.
        evidence:
          type: string
          minLength: 1
          maxLength: 2000
          description: What you checked, and what you found. It goes on the audit trail.
          example: ME53N shows 10000124 not released; its change log has no entry after 12:00
        transaction_ref:
          type: string
          minLength: 1
          maxLength: 256
          description: The target's own reference for the write, if you found one.

    ConnectorTestResult:
      type: object
      properties:
        ok:
          type: boolean
        message:
          type: string
          example: Connected — Table API reachable, requested items visible
        http_status:
          type: integer
          example: 200

    TenantIdpConfig:
      type: object
      properties:
        tenant_id:
          type: string
        identity_providers:
          $ref: "#/components/schemas/TenantIdpConfigUpdate"

    TenantIdpConfigUpdate:
      type: object
      required: [enabled_providers]
      properties:
        enabled_providers:
          type: array
          items:
            type: string
            enum:
              [
                API_KEY,
                COGNITO,
                EUDI_WALLET,
                AUTH0,
                OKTA,
                ENTRA_ID,
                GOOGLE,
                AWS_IAM_IDC,
                GENERIC_OIDC,
              ]
        auth0:
          type: object
          properties:
            domain:
              type: string
              example: "your-tenant.auth0.com"
            audience:
              type: string
            client_id:
              type: string
        okta:
          type: object
          properties:
            issuer:
              type: string
              example: "https://your-org.okta.com/oauth2/default"
            audience:
              type: string
        entra:
          type: object
          properties:
            tenant_id:
              type: string
              format: uuid
            client_id:
              type: string
              format: uuid
        eudi:
          type: object
          properties:
            rp_id:
              type: string
            presentation_policies:
              type: array
              items:
                type: string
        google:
          type: object
          properties:
            client_id:
              type: string
              example: "xxxx.apps.googleusercontent.com"
            hosted_domain:
              type: string
              description: Restrict to Google Workspace org (optional)
              example: "company.com"
        aws_iam_idc:
          type: object
          properties:
            issuer:
              type: string
              format: uri
              example: "https://identitycenter.amazonaws.com/ssoins-xxxxx"
            audience:
              type: string
        generic_oidc:
          type: object
          required: [name, issuer, audience, subject_prefix]
          properties:
            name:
              type: string
              description: Display name for the provider
              example: "Keycloak"
            issuer:
              type: string
              format: uri
              description: OIDC issuer URL (must serve /.well-known/openid-configuration)
              example: "https://auth.company.com/realms/production"
            audience:
              type: string
              example: "mandaitor-client"
            subject_prefix:
              type: string
              description: Prefix for canonical subject IDs (e.g., keycloak → oidc:keycloak:<sub>)
              pattern: "^[a-z][a-z0-9-]*$"
              example: "keycloak"

    RbacRole:
      type: string
      description: Canonical RBAC role surfaced by the authorizer. `platform_admin` is reserved for platform operations and cannot be assigned through tenant role policy.
      enum:
        - platform_admin
        - tenant_admin
        - tenant_operator
        - tenant_auditor
        - tenant_viewer
        - service

    RbacPermission:
      type: string
      description: Canonical RBAC permission surfaced by the authorizer and enforced by tenant-aware guards.
      enum:
        - "*"
        - platform:admin
        - platform:onboarding:read
        - platform:onboarding:write
        - platform:tenants:read
        - platform:tenants:write
        - tenant:read
        - tenant:write
        - tenant:billing:read
        - tenant:usage:read
        - tenant:audit:read
        - tenant:api-keys:read
        - tenant:api-keys:write
        - mandates:read
        - mandates:write
        - verify:execute
        - events:read
        - events:export
        - evidence:read
        - evidence:write

    TenantSubjectType:
      type: string
      enum: [NATURAL_PERSON, LEGAL_ENTITY, SERVICE, AGENT]

    TenantRoleAssignmentRequest:
      type: object
      required: [roles]
      additionalProperties: false
      properties:
        subject_type:
          allOf:
            - $ref: "#/components/schemas/TenantSubjectType"
          default: NATURAL_PERSON
        display_name:
          type: string
          minLength: 1
          maxLength: 200
          example: "Amina Bauer"
        email:
          type: string
          format: email
          maxLength: 320
          example: "amina.bauer@example.com"
        roles:
          type: array
          minItems: 1
          maxItems: 5
          description: Tenant-assignable RBAC roles. `platform_admin` is rejected for this endpoint.
          items:
            $ref: "#/components/schemas/RbacRole"
          example: [tenant_auditor, tenant_viewer]
        permissions:
          type: array
          maxItems: 30
          description: Optional explicit tenant-scoped permissions merged with role-derived grants. Platform permissions are rejected.
          items:
            $ref: "#/components/schemas/RbacPermission"
          example: [events:read, evidence:read]

    TenantRoleAssignment:
      type: object
      required:
        - tenant_id
        - subject_id
        - subject_type
        - roles
        - permissions
        - status
        - created_at
        - updated_at
        - assigned_by
      properties:
        tenant_id:
          type: string
        subject_id:
          type: string
          example: "eudi:DE/1234567890abcdef"
        subject_type:
          $ref: "#/components/schemas/TenantSubjectType"
        display_name:
          type: string
          example: "Amina Bauer"
        email:
          type: string
          format: email
          example: "amina.bauer@example.com"
        roles:
          type: array
          items:
            $ref: "#/components/schemas/RbacRole"
        permissions:
          type: array
          description: Effective tenant permissions after role-derived and explicit grants have been normalized.
          items:
            $ref: "#/components/schemas/RbacPermission"
        status:
          type: string
          enum: [ACTIVE, REVOKED]
          example: ACTIVE
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        assigned_by:
          type: string
          example: "eudi:DE/admin-subject"
        revoked_at:
          type: string
          format: date-time
        revoked_by:
          type: string

    TenantRoleAssignmentList:
      type: object
      required: [tenant_id, assignments]
      properties:
        tenant_id:
          type: string
        assignments:
          type: array
          items:
            $ref: "#/components/schemas/TenantRoleAssignment"

    WorkspaceEntitlementRequest:
      type: object
      required: [product_workspace, partner_project_workspace]
      additionalProperties: false
      properties:
        product_workspace:
          type: boolean
          description: Grants access to the Product Workspace when the entitlement is active.
          example: true
        partner_project_workspace:
          type: boolean
          description: Grants access to the Partner & Project Workspace when the entitlement is active.
          example: true

    WorkspaceEntitlement:
      type: object
      required: [tenant_id, user_id, workspaces, status, updated_at, source]
      properties:
        tenant_id:
          type: string
          example: "tenant_demo"
        user_id:
          type: string
          example: "eudi:DE/1234567890abcdef"
        workspaces:
          type: object
          required: [product_workspace, partner_project_workspace]
          additionalProperties: false
          properties:
            product_workspace:
              type: boolean
              example: true
            partner_project_workspace:
              type: boolean
              example: true
        status:
          type: string
          enum: [ACTIVE, REVOKED]
          example: ACTIVE
        granted_by:
          type: string
          example: "eudi:DE/admin-subject"
        granted_at:
          type: string
          format: date-time
        updated_by:
          type: string
          example: "eudi:DE/admin-subject"
        updated_at:
          type: string
          format: date-time
        source:
          type: string
          enum: [explicit, default]
          description: Indicates whether the response comes from an explicit entitlement row or default RBAC-derived access.
          example: explicit

    # ─── EUDI Wallet Schemas ──────────────────────────────────

    EudiSessionResponse:
      type: object
      description: Response from initiating an EUDI Wallet verification session.
      properties:
        session_id:
          type: string
          example: "eudi_01HXYZ..."
        status:
          type: string
          enum: [PENDING]
        request_uri:
          type: string
          format: uri
          description: URL where the wallet fetches the Authorization Request Object
        qr_code_uri:
          type: string
          description: URI to encode as QR code (cross-device flow)
        deep_link_uri:
          type: string
          description: URI to open as deep link (same-device flow)
        expires_at:
          type: string
          format: date-time

    EudiSessionStatus:
      type: object
      description: Status of an EUDI Wallet verification session.
      properties:
        session_id:
          type: string
        status:
          type: string
          enum: [PENDING, COMPLETED, FAILED, EXPIRED]
        created_at:
          type: string
          format: date-time
        presentation_definition_id:
          type: string
        resolved_identity:
          type: object
          description: Resolved identity from the EUDI Wallet (present when COMPLETED)
          properties:
            subject_id:
              type: string
              example: "eudi:DE/1234567890abcdef"
            subject_type:
              type: string
              enum: [NATURAL_PERSON]
            provider:
              type: string
              enum: [EUDI_WALLET]
            display_name:
              type: string
            assurance_level:
              type: string
              enum: [HIGH]
            eidas_attributes:
              $ref: "#/components/schemas/EidasAttributes"
        verified_at:
          type: string
          format: date-time
        errors:
          type: array
          items:
            type: string

    EidasAttributes:
      type: object
      description: eIDAS 2.0 Person Identification Data attributes.
      properties:
        unique_id:
          type: string
          description: Unique persistent identifier from the Member State
        family_name:
          type: string
        given_name:
          type: string
        birth_date:
          type: string
          format: date
        nationality:
          type: string
          description: ISO 3166-1 alpha-2
        pid_issuer:
          type: string
        issuing_country:
          type: string
          description: ISO 3166-1 alpha-2
        assurance_level:
          type: string
          enum: [LOW, SUBSTANTIAL, HIGH]

    # ── @experimental — Drift Detection & Semantic Verification ──

    MandateIntent:
      type: object
      description: |
        **@experimental** Intent declaration for drift detection.
        Allows the principal to express the intended purpose and expected
        behavioral clusters for a mandate. Used by the drift detection
        engine to compute semantic distance scores.
      properties:
        purpose:
          type: string
          description: Human-readable purpose of the mandate
          example: "Automated structural validation of Zone EG installations"
        expected_clusters:
          type: array
          description: Expected action cluster IDs from the taxonomy's semantic graph
          items:
            type: string
          example: ["validation", "inspection"]
        drift_threshold:
          type: number
          format: float
          description: "Drift threshold (0.0–1.0). Default: 0.7"
          minimum: 0.0
          maximum: 1.0
          default: 0.7

    DriftScore:
      type: object
      description: |
        **@experimental** Multi-dimensional drift score computed from
        a sliding session window of recent verification events.
      properties:
        semantic_distance:
          type: number
          format: float
          description: How far actions stray from expected clusters (0.0–1.0)
        sequence_deviation:
          type: number
          format: float
          description: Workflow order violations (0.0–1.0)
        frequency_anomaly:
          type: number
          format: float
          description: Unusual action frequency patterns (0.0–1.0)
        scope_expansion:
          type: number
          format: float
          description: Actions outside declared scope (0.0–1.0)
        aggregate:
          type: number
          format: float
          description: Weighted aggregate score (0.0–1.0)
        drift_detected:
          type: boolean
          description: Whether the aggregate exceeds the mandate's drift threshold

    AgentDriftSummary:
      type: object
      description: |
        **@experimental** Aggregated drift summary for a single delegate agent
        across all mandates within the tenant.
      required:
        - delegate_subject_id
        - mandate_count
        - avg_drift_score
        - max_drift_score
        - sessions_with_drift
        - total_sessions
        - last_activity
      properties:
        delegate_subject_id:
          type: string
          description: Unique identifier of the delegate agent
        display_name:
          type: string
          nullable: true
          description: Human-readable display name of the agent (if available)
        mandate_count:
          type: integer
          description: Number of active mandates for this agent
        avg_drift_score:
          type: number
          format: float
          description: Average aggregate drift score across all sessions (0.0–1.0)
        max_drift_score:
          type: number
          format: float
          description: Maximum aggregate drift score observed (0.0–1.0)
        sessions_with_drift:
          type: integer
          description: Number of sessions where drift was detected
        total_sessions:
          type: integer
          description: Total number of drift sessions evaluated
        last_activity:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the most recent session

    DriftEvent:
      type: object
      description: |
        **@experimental** A drift detection or resolution event from the
        immutable audit trail.
      required:
        - event_id
        - timestamp
        - mandate_id
        - delegate_subject_id
        - action
        - drift_score
      properties:
        event_id:
          type: string
          description: Unique event identifier
        timestamp:
          type: string
          format: date-time
          description: ISO 8601 timestamp when the event was recorded
        mandate_id:
          type: string
          description: ID of the mandate associated with this event
        delegate_subject_id:
          type: string
          description: Delegate agent that triggered the event
        action:
          type: string
          enum: [DRIFT_DETECTED, DRIFT_RESOLVED]
          description: Type of drift event
        drift_score:
          $ref: "#/components/schemas/DriftScore"

    DriftSessionResponse:
      type: object
      description: |
        **@experimental** A single drift session window for a delegate agent,
        containing the computed drift score and session metadata.
      required:
        - mandate_id
        - delegate_subject_id
        - window_start
        - window_end
        - action_count
        - drift_score
      properties:
        mandate_id:
          type: string
          description: ID of the mandate this session belongs to
        delegate_subject_id:
          type: string
          description: Delegate agent identifier
        window_start:
          type: string
          format: date-time
          description: Start of the session window (ISO 8601)
        window_end:
          type: string
          format: date-time
          description: End of the session window (ISO 8601)
        action_count:
          type: integer
          description: Number of actions within this session window
        drift_score:
          $ref: "#/components/schemas/DriftScore"
        intent_purpose:
          type: string
          nullable: true
          description: Declared intent/purpose from the mandate scope (if available)

    SemanticConflict:
      type: object
      description: A detected conflict between two actions in the semantic graph.
      properties:
        action_a:
          type: string
        action_b:
          type: string
        weight:
          type: number
          format: float
        rationale:
          type: string

    SemanticSignals:
      type: object
      description: |
        **@experimental** Advisory semantic signals returned alongside
        verification decisions. These signals NEVER affect the ALLOW/DENY
        decision and are provided for observability and governance purposes.
      properties:
        drift_score:
          $ref: "#/components/schemas/DriftScore"
        conflicts:
          type: array
          items:
            $ref: "#/components/schemas/SemanticConflict"
        sequence_violations:
          type: array
          items:
            type: object
            properties:
              action:
                type: string
              violation:
                type: string
              expected:
                type: string
        agent_drift_score:
          type: number
          format: float
          description: Cross-mandate aggregate drift score for this delegate

    VerifyResponseWithSemantics:
      description: |
        **@experimental** Extended verification response that includes
        semantic drift signals alongside the standard decision.
      allOf:
        - $ref: "#/components/schemas/VerifyResponseWithPoM"
        - type: object
          properties:
            semantic_signals:
              $ref: "#/components/schemas/SemanticSignals"

    Error:
      type: object
      description: Standard error response containing an error code and human-readable message.
      properties:
        error:
          type: string
          example: "BAD_REQUEST"
        message:
          type: string
          example: "principal and delegate are required"

    KillSwitchAccepted:
      type: object
      description: A queued kill-switch execution; poll `status_path` for progress.
      properties:
        tenant_id:
          type: string
          maxLength: 128
        execution_id:
          type: string
          maxLength: 64
          description: Unique ID for this kill-switch execution
          example: "ks_01HXYZ..."
        status:
          type: string
          enum: [QUEUED]
        status_path:
          type: string
          maxLength: 256
          description: Path of the execution status resource
          example: "/admin/tenants/tnt_ABC123/kill-switch/ks_01HXYZ..."
        requested_at:
          type: string
          format: date-time
        requested_by:
          type: string
          maxLength: 256
          description: Platform admin who triggered the kill-switch

    KillSwitchExecution:
      type: object
      description: |
        Progress record of one kill-switch execution. Counts add up page by
        page while the status is RUNNING and are final once it is COMPLETED
        or FAILED. A FAILED execution keeps the counts it reached and its
        last error; re-triggering the kill-switch starts a new execution
        that picks up the tools still enabled, the executions not yet
        stopped and the mandates still ACTIVE, SUSPENDED or
        PENDING_APPROVAL.
      properties:
        execution_id:
          type: string
          maxLength: 64
        tenant_id:
          type: string
          maxLength: 128
        status:
          type: string
          enum: [QUEUED, RUNNING, COMPLETED, FAILED]
        reason:
          type: string
          maxLength: 1000
        requested_by:
          type: string
          maxLength: 256
        requested_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        phase:
          type: string
          maxLength: 16
          enum: [TOOLS, EXECUTIONS, MANDATES]
          description: What the worker is doing, in this order. Absent on an execution recorded before tools and executions were stopped.
        tools_disabled:
          type: array
          maxItems: 1000
          description: The tools it disabled, in every environment. The tenant enables each again, in its environment and on its connector, once the incident is over.
          items:
            type: object
            required: [tool_id, version, connector_id]
            properties:
              tool_id:
                type: string
                maxLength: 128
              version:
                type: integer
                minimum: 1
              environment:
                allOf:
                  - $ref: "#/components/schemas/Environment"
                description: Absent on records made before environments existed, all in `default`.
              connector_id:
                type: string
                maxLength: 128
        executions_cancelled:
          type: integer
          description: Executions it stopped while they waited for an approval or for their dispatch, each DENIED with KILL_SWITCH
        executions_in_flight:
          type: integer
          description: Executions already dispatched (EXECUTING, UNKNOWN); they finish, and nothing recalls them
        executions_busy:
          type: integer
          description: Executions another writer held when the worker reached them; each ends refused, its tool no longer enabled
        executions_failed:
          type: integer
          description: Executions whose denial could not be recorded on the audit chain; each is refused by the same check
        revoked_count:
          type: integer
          description: Mandates this execution moved to REVOKED
        skipped_count:
          type: integer
          description: Mandates that needed nothing (already REVOKED or EXPIRED, never submitted, or changed by someone else meanwhile)
        failed_count:
          type: integer
          description: Mandates whose status write failed; still ACTIVE, SUSPENDED or PENDING_APPROVAL
        audit_event_failures:
          type: integer
          description: Revoked mandates whose MANDATE_REVOKED audit event could not be written
        pages_processed:
          type: integer
        continuations:
          type: integer
          description: Worker invocations after the first, for a run that outgrew one invocation
        attempts:
          type: integer
          description: Delivery attempts of the current queue message
        last_error:
          type: string
          maxLength: 512

    # ─── Horizon 1: VI Credential Schemas ──────────────────────

    ViCredentialRequest:
      type: object
      required: [audience]
      properties:
        audience:
          type: string
          description: Target verifier audience URI
        nonce:
          type: string
          description: Optional nonce for replay protection
        agent_public_key:
          type: object
          description: Agent's JWK public key for L2 credential
        principal_public_key:
          type: object
          description: Principal's JWK public key for L1 credential
        principal_key_id:
          type: string
          description: KMS key ID for principal signing

    ViCredentialResponse:
      type: object
      properties:
        credential_chain:
          type: object
          description: The VI credential chain (L1 + L2 credentials)
          properties:
            l1_credential:
              type: string
              description: L1 (Principal Intent) SD-JWT credential
            l2_credential:
              type: string
              description: L2 (Autonomous Agent) SD-JWT credential
            chain_id:
              type: string
            spec_version:
              type: string
              example: "1.0"
        format:
          type: string
          example: "vi-sd-jwt"
        spec_version:
          type: string
          example: "1.0"

    # ─── Horizon 1: Agent Registry Schemas ─────────────────────

    CreateAgentRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Human-readable agent name
        description:
          type: string
          description: Agent description
        algorithm:
          type: string
          enum: [ES256, ES384, EdDSA]
          default: ES256
          description: Key algorithm for the agent's verification method
        public_key_jwk:
          $ref: "#/components/schemas/ClientPublicKeyJwk"
        metadata:
          type: object
          additionalProperties: true
          description: Arbitrary metadata attached to the agent
        environment:
          allOf:
            - $ref: "#/components/schemas/Environment"
          description: |
            The environment the agent acts in, `default` when not given. Fixed
            at registration: an agent that must act in two environments is
            registered twice. Its executions use the tools enabled in this
            environment, which run on connectors of this environment only.

    ClientPublicKeyJwk:
      type: object
      description: |
        Bring-your-own-key registration: the agent generates its key pair
        locally and registers only the public half. Required for any agent that
        authenticates with `private_key_jwt` (e.g. `POST /mcp/token`) — a
        KMS-managed key has no signing API, so an external agent could never
        produce the client_assertion. When omitted, a KMS-managed key is
        provisioned for Mandaitor-hosted signing use cases instead.
      required: [kty, crv, x, y]
      properties:
        kty:
          type: string
          enum: [EC]
        crv:
          type: string
          enum: [P-256, P-384]
        x:
          type: string
          description: base64url X coordinate
        y:
          type: string
          description: base64url Y coordinate
        alg:
          type: string
          enum: [ES256, ES384]
          description: Optional; must match the curve when present

    AgentIdentity:
      type: object
      properties:
        agent_id:
          type: string
        tenant_id:
          type: string
        did:
          type: string
          description: "DID of the agent (did:web format)"
        name:
          type: string
        description:
          type: string
        status:
          type: string
          enum: [ACTIVE, SUSPENDED, REVOKED]
        verification_methods:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              type:
                type: string
              publicKeyJwk:
                type: object
              status:
                type: string
                enum: [ACTIVE, ROTATED, REVOKED]
        bound_mandates:
          type: array
          items:
            type: string
        environment:
          allOf:
            - $ref: "#/components/schemas/Environment"
          description: |
            The environment the agent acts in, fixed at registration. Absent
            on agents registered before environments existed, which are in
            `default`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ─── Horizon 1: MCP Authorization Schemas ──────────────────

    MCPServerMetadata:
      type: object
      properties:
        issuer:
          type: string
        token_endpoint:
          type: string
        authorization_endpoint:
          type: string
        grant_types_supported:
          type: array
          items:
            type: string
        token_endpoint_auth_methods_supported:
          type: array
          items:
            type: string
        token_endpoint_auth_signing_alg_values_supported:
          type: array
          items:
            type: string
          example: [ES256, ES384]
        dpop_signing_alg_values_supported:
          type: array
          description: The algorithms a DPoP proof may be signed with (RFC 9449 §5.1).
          items:
            type: string
          example: [ES256, ES384]
        response_types_supported:
          type: array
          description: Empty. No browser flow is offered.
          items:
            type: string
        service_documentation:
          type: string
        scopes_supported:
          type: array
          items:
            type: string

    MCPTokenRequest:
      type: object
      required: [grant_type, client_assertion_type, client_assertion]
      properties:
        grant_type:
          type: string
          enum: [client_credentials]
        client_id:
          type: string
          description: Agent DID. Optional, as the assertion's `iss` names the agent; when sent, it must agree.
        client_assertion_type:
          type: string
          enum: ["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"]
        client_assertion:
          type: string
          description: >-
            Signed JWT assertion, accepted once. Carries a unique `jti` and an `exp`
            no more than five minutes ahead.
        scope:
          type: string
          description: Space-separated mandate scopes
        resource:
          type: string
          description: >-
            What the token is for (RFC 8707): the MCP server's URL, or the API's base URL. A
            session is accepted only by the one it names; without a resource, by the API.
          example: https://api.mandaitor.io/v1/mcp

    OAuthError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          example: invalid_target
        error_description:
          type: string

    McpJsonRpcMessage:
      type: object
      description: One JSON-RPC 2.0 message, a request (with `id`), a notification (without), or a response.
      required: [jsonrpc]
      properties:
        jsonrpc:
          type: string
          enum: ["2.0"]
        id:
          oneOf:
            - type: string
            - type: integer
        method:
          type: string
          example: tools/call
        params:
          type: object
          additionalProperties: true

    McpJsonRpcResponse:
      type: object
      required: [jsonrpc]
      properties:
        jsonrpc:
          type: string
          enum: ["2.0"]
        id:
          description: The request's id. Absent when the server did not read the message, because it could not or because it refused the request first.
          oneOf:
            - type: string
            - type: integer
        result:
          type: object
          additionalProperties: true
          description: For `tools/call`, a CallToolResult, whose `structuredContent` is the execution.
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: integer
            message:
              type: string
            data:
              description: More about the error, when there is more.

    MCPTokenResponse:
      type: object
      properties:
        access_token:
          type: string
        token_type:
          type: string
          enum: [DPoP, Bearer]
          description: >-
            `DPoP` for a session bound to the key of the request's DPoP proof, sent as
            `Authorization: DPoP …` with a proof on every request; `Bearer` for one issued
            without a proof.
          example: "DPoP"
        expires_in:
          type: integer
          description: Token lifetime in seconds. 3600 for a session bound to a key, 900 for a bearer one.
          example: 3600
        scope:
          type: string
        session_id:
          type: string
          description: The session's id, for `GET /mcp/sessions/{id}`.
          maxLength: 37
          example: sess_0f3c9d2e8b7a6c5d4e3f2a1b0c9d8e7f

    MCPSession:
      type: object
      properties:
        session_id:
          type: string
        tenant_id:
          type: string
        agent_id:
          type: string
        agent_did:
          type: string
        mandate_ids:
          type: array
          items:
            type: string
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        last_active_at:
          type: string
          format: date-time
          description: >-
            When the session was issued. `POST /mcp/authorize` moved it on each decision until it
            retired (execution roadmap E2.11).
        call_count:
          type: integer
          description: >-
            Tool calls `POST /mcp/authorize` decided in this session. Nothing has counted calls since
            it retired (execution roadmap E2.11), so a session issued since reads 0.
        cnf:
          type: object
          description: >-
            For a session bound to a key (DPoP): the key's SHA-256 thumbprint (RFC 7638), which
            every proof sent with it must match.
          properties:
            jkt:
              type: string
              maxLength: 43

    # ── Horizon 2: Consent UX Schemas ─────────────────────────

    CreateConsentRequest:
      type: object
      description: |
        A consent request for one tool call. The approver is the mandate's
        principal. A consent policy may approve or reject it at once.
      required: [mandate_id, agent_id, tool_call, reason]
      properties:
        mandate_id:
          type: string
          description: The mandate the call falls under.
        agent_id:
          type: string
          description: The agent that would make the call.
        agent_did:
          type: string
        tool_call:
          type: object
          required: [tool_name]
          properties:
            tool_name:
              type: string
            arguments:
              type: object
        reason:
          type: string
          description: Why consent is needed, as the approver reads it.
        amount:
          type: number
          description: The amount at stake, when there is one.
        currency:
          type: string
        urgency:
          type: string
          enum: [LOW, NORMAL, HIGH, CRITICAL]
        ttl_seconds:
          type: integer
          description: How long the request stays open. Default 3600.

    ConsentRequest:
      type: object
      properties:
        consent_id:
          type: string
        tenant_id:
          type: string
        mandate_id:
          type: string
        agent_id:
          type: string
        agent_did:
          type: string
        approver_subject_id:
          type: string
        approver_display_name:
          type: string
        status:
          type: string
          enum: [PENDING, APPROVED, REJECTED, EXPIRED, AUTO_APPROVED, SUPERSEDED]
          description: |
            `SUPERSEDED`: an execution's approval that a newer one replaced,
            because what it covered changed.
        urgency:
          type: string
          enum: [LOW, NORMAL, HIGH, CRITICAL]
        tool_call:
          type: object
          description: The call the consent is for.
          properties:
            tool_name:
              type: string
            arguments:
              type: object
        reason:
          type: string
        amount:
          type: number
        currency:
          type: string
        decision:
          type: object
          description: Once it is decided.
          properties:
            decided_by:
              type: string
            decided_at:
              type: string
              format: date-time
            comment:
              type: string
            auto_policy_id:
              type: string
              description: The consent policy that decided it, if one did.
        execution:
          type: object
          nullable: true
          description: |
            Set when the consent is an execution's approval: what the
            approver approves. Decide it with
            `POST /executions/{execution_id}/approval`, sending
            `approval_digest`. No consent policy resolves it.
          properties:
            execution_id:
              type: string
            approval_digest:
              type: string
            tool:
              type: object
              properties:
                id:
                  type: string
                version:
                  type: integer
            scope:
              type: object
              properties:
                action:
                  type: string
                resource:
                  type: string
            facts:
              type: object
              description: The facts the decision rested on, as read from the target.
            target:
              type: object
              properties:
                connector_id:
                  type: string
                record:
                  type: string
                version:
                  type: string
            require_mfa:
              type: boolean
            rules:
              type: array
              items:
                type: string
            purpose:
              type: string
        notification:
          type: object
          description: |
            Whether the approver was told by email that the request waits
            for them. The email names the tenant, why approval is needed and
            when the request expires, and links to it in the dashboard; it
            carries nothing of the request itself, and nothing in it decides
            it. Absent until the notifier reaches the request, and on one
            that had expired or been decided before it did.
          required: [status, channel, at]
          properties:
            status:
              type: string
              enum: [SENDING, SENT, NOT_SENT]
              description: |
                `SENT`: the mail service took the email. `NOT_SENT`: see
                `reason`; the request waits in the approver's inbox all the
                same.
            reason:
              type: string
              enum: [NO_ADDRESS, RATE_LIMITED, DECIDED_BY_POLICY]
              description: |
                Why it was not sent. `NO_ADDRESS`: the approver has neither
                a verified sign-in email in the tenant nor an email on an
                active role assignment there. `RATE_LIMITED`: the approver
                had been sent as many notices that hour in the tenant as
                they may be. `DECIDED_BY_POLICY`: a consent policy decided
                the request as it was made, so it never waited.
            channel:
              type: string
              enum: [email]
            at:
              type: string
              format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time

    ResolveConsentRequest:
      type: object
      required: [decision]
      properties:
        decision:
          type: string
          enum: [APPROVED, REJECTED]
        comment:
          type: string
          description: The approver's comment, kept with the decision.

    PaginatedConsentRequests:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ConsentRequest"
        count:
          type: integer
          description: The number of items.

    CreateConsentPolicy:
      type: object
      required: [name, rules]
      properties:
        name:
          type: string
          description: Human-readable policy name
        description:
          type: string
        rules:
          type: array
          items:
            $ref: "#/components/schemas/ConsentPolicyRule"
        priority:
          type: integer
          description: Higher priority policies are evaluated first (default 0)
        enabled:
          type: boolean
          default: true

    UpdateConsentPolicy:
      type: object
      properties:
        name:
          type: string
        description:
          type: string
        rules:
          type: array
          items:
            $ref: "#/components/schemas/ConsentPolicyRule"
        priority:
          type: integer
        enabled:
          type: boolean

    ConsentPolicy:
      type: object
      properties:
        policy_id:
          type: string
        tenant_id:
          type: string
        name:
          type: string
        description:
          type: string
        rules:
          type: array
          items:
            $ref: "#/components/schemas/ConsentPolicyRule"
        priority:
          type: integer
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ConsentPolicyRule:
      type: object
      required: [effect]
      properties:
        effect:
          type: string
          enum: [auto_approve, require_approval]
        agent_ids:
          type: array
          items:
            type: string
          description: Specific agent IDs this rule applies to (empty = all)
        actions:
          type: array
          items:
            type: string
          description: Action patterns to match (glob supported)
        resources:
          type: array
          items:
            type: string
          description: Resource patterns to match (glob supported)
        max_amount:
          type: number
          description: Maximum transaction amount for auto-approval
        time_window:
          type: object
          properties:
            start:
              type: string
              description: Time of day (HH:mm) when rule is active
            end:
              type: string
              description: Time of day (HH:mm) when rule becomes inactive
            days:
              type: array
              items:
                type: string
                enum: [mon, tue, wed, thu, fri, sat, sun]

    PaginatedConsentPolicies:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ConsentPolicy"
        next_cursor:
          type: string
        total_count:
          type: integer

    # ── Agent Marketplace Schemas ─────────────────────────────

    CapabilityCategory:
      type: string
      enum:
        - DOCUMENT_PROCESSING
        - DATA_ANALYSIS
        - COMMUNICATION
        - FINANCIAL
        - LEGAL
        - COMPLIANCE
        - IDENTITY
        - INTEGRATION
        - AUTOMATION
        - CUSTOM

    PricingModel:
      type: string
      enum: [FREE, PER_CALL, PER_MINUTE, MONTHLY, CUSTOM]

    CapabilityDeclaration:
      type: object
      required: [capability_id, name, description, category, required_scopes]
      properties:
        capability_id:
          type: string
          description: "Machine-readable capability ID (e.g., doc:summarize)"
        name:
          type: string
        description:
          type: string
        category:
          $ref: "#/components/schemas/CapabilityCategory"
        required_scopes:
          type: array
          items:
            type: string
        input_schema:
          type: object
        output_schema:
          type: object
        avg_execution_ms:
          type: integer
        max_execution_ms:
          type: integer

    PricingInfo:
      type: object
      required: [model]
      properties:
        model:
          $ref: "#/components/schemas/PricingModel"
        unit_price:
          type: integer
          description: Price in smallest currency unit (cents)
        currency:
          type: string
          description: ISO 4217 currency code
        free_tier_limit:
          type: integer
        custom_description:
          type: string

    TrustMetrics:
      type: object
      properties:
        trust_score:
          type: number
          minimum: 0
          maximum: 1
        avg_rating:
          type: number
          minimum: 1
          maximum: 5
        review_count:
          type: integer
        total_invocations:
          type: integer
        success_rate:
          type: number
          minimum: 0
          maximum: 1
        avg_response_ms:
          type: number
        drift_score:
          type: number
          minimum: 0
          maximum: 1
        uptime:
          type: number
          minimum: 0
          maximum: 1
        computed_at:
          type: string
          format: date-time

    MarketplaceListing:
      type: object
      required:
        - listing_id
        - agent_id
        - owner_tenant_id
        - status
        - display_name
        - tagline
        - description
        - capabilities
        - pricing
        - trust_metrics
        - tags
        - version
        - created_at
        - updated_at
      properties:
        listing_id:
          type: string
        agent_id:
          type: string
        owner_tenant_id:
          type: string
        status:
          type: string
          enum: [DRAFT, PUBLISHED, SUSPENDED, ARCHIVED]
        display_name:
          type: string
        tagline:
          type: string
          maxLength: 120
        description:
          type: string
        logo_url:
          type: string
          format: uri
        docs_url:
          type: string
          format: uri
        capabilities:
          type: array
          items:
            $ref: "#/components/schemas/CapabilityDeclaration"
        pricing:
          $ref: "#/components/schemas/PricingInfo"
        trust_metrics:
          $ref: "#/components/schemas/TrustMetrics"
        tags:
          type: array
          items:
            type: string
        supported_regions:
          type: array
          items:
            type: string
        certifications:
          type: array
          items:
            type: string
        version:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        published_at:
          type: string
          format: date-time

    CreateListingRequest:
      type: object
      required: [agent_id, display_name, tagline, description, capabilities, pricing]
      properties:
        agent_id:
          type: string
        display_name:
          type: string
        tagline:
          type: string
          maxLength: 120
        description:
          type: string
        logo_url:
          type: string
          format: uri
        docs_url:
          type: string
          format: uri
        capabilities:
          type: array
          items:
            $ref: "#/components/schemas/CapabilityDeclaration"
          minItems: 1
        pricing:
          $ref: "#/components/schemas/PricingInfo"
        tags:
          type: array
          items:
            type: string
        supported_regions:
          type: array
          items:
            type: string
        certifications:
          type: array
          items:
            type: string

    UpdateListingRequest:
      type: object
      properties:
        display_name:
          type: string
        tagline:
          type: string
          maxLength: 120
        description:
          type: string
        logo_url:
          type: string
          format: uri
        docs_url:
          type: string
          format: uri
        capabilities:
          type: array
          items:
            $ref: "#/components/schemas/CapabilityDeclaration"
        pricing:
          $ref: "#/components/schemas/PricingInfo"
        tags:
          type: array
          items:
            type: string
        supported_regions:
          type: array
          items:
            type: string
        certifications:
          type: array
          items:
            type: string

    AgentReview:
      type: object
      required:
        [review_id, listing_id, agent_id, reviewer_tenant_id, rating, created_at, updated_at]
      properties:
        review_id:
          type: string
        listing_id:
          type: string
        agent_id:
          type: string
        reviewer_tenant_id:
          type: string
        rating:
          type: integer
          minimum: 1
          maximum: 5
        comment:
          type: string
        capability_id:
          type: string
        consent_request_id:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CreateReviewRequest:
      type: object
      required: [listing_id, rating]
      properties:
        listing_id:
          type: string
        rating:
          type: integer
          minimum: 1
          maximum: 5
        comment:
          type: string
        capability_id:
          type: string
        consent_request_id:
          type: string

    DiscoveryResult:
      type: object
      required: [listings, total_count]
      properties:
        listings:
          type: array
          items:
            $ref: "#/components/schemas/MarketplaceListing"
        next_cursor:
          type: string
        total_count:
          type: integer

    PaginatedListings:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/MarketplaceListing"
        next_cursor:
          type: string
        total_count:
          type: integer

    PaginatedReviews:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AgentReview"
        next_cursor:
          type: string
        total_count:
          type: integer

    # ─── Partner Workspace: knowledge catalog + artifacts ─────────

    MediaClassification:
      type: string
      enum: [public, partner-confidential, client-confidential, internal-restricted]

    KnowledgeAssetStatus:
      type: string
      enum: [planned, draft, release-candidate, approved, uploaded, superseded, archived, withdrawn]

    MediaReviewStatus:
      type: string
      enum: [draft, approved, uploaded, superseded, archived, withdrawn]

    MediaFileRole:
      type: string
      enum:
        [primary, export, thumbnail, caption, transcript, source-bundle, manifest-supporting, other]

    KnowledgeAssetReview:
      type: object
      properties:
        required_reviewers:
          type: array
          items:
            type: string
        last_reviewed_at:
          type: string
          format: date-time
          nullable: true
        notes:
          type: string

    KnowledgeAsset:
      type: object
      properties:
        asset_id:
          type: string
        tenant_id:
          type: string
        title:
          type: string
        status:
          $ref: "#/components/schemas/KnowledgeAssetStatus"
        classification:
          $ref: "#/components/schemas/MediaClassification"
        lifecycle_area:
          type: string
        lifecycle_phases:
          type: array
          items:
            type: string
        primary_audience:
          type: array
          items:
            type: string
        owner:
          type: string
        source_references:
          type: array
          items:
            type: string
          description: 'Repository paths, URLs, or "engagement:{projectId}" links'
        current_version:
          type: string
          nullable: true
        storage_prefix:
          type: string
          nullable: true
        manifest_path:
          type: string
          nullable: true
        review:
          $ref: "#/components/schemas/KnowledgeAssetReview"
        created_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    MediaFile:
      type: object
      properties:
        file_id:
          type: string
        tenant_id:
          type: string
        asset_id:
          type: string
        version:
          type: string
        storage_key:
          type: string
        relative_path:
          type: string
        file_name:
          type: string
        content_type:
          type: string
        size_bytes:
          type: integer
        sha256:
          type: string
          description: Hex digest, or "pending" when never announced
        role:
          $ref: "#/components/schemas/MediaFileRole"
        language:
          type: string
        format:
          type: string
        created_at:
          type: string
          format: date-time

    MediaVersion:
      type: object
      properties:
        version_id:
          type: string
        tenant_id:
          type: string
        asset_id:
          type: string
        version:
          type: string
        status:
          $ref: "#/components/schemas/MediaReviewStatus"
        classification:
          $ref: "#/components/schemas/MediaClassification"
        storage_bucket_env:
          type: string
        storage_bucket:
          type: string
          nullable: true
        storage_prefix:
          type: string
        manifest_s3_key:
          type: string
        manifest_path:
          type: string
          nullable: true
        release_notes:
          type: string
          nullable: true
        supersedes_version:
          type: string
          nullable: true
        approved_by:
          type: string
          nullable: true
          description: Set server-side when a version is registered with status "approved"
        approved_at:
          type: string
          format: date-time
          nullable: true
        source_commit:
          type: string
          nullable: true
        created_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        files:
          type: array
          items:
            $ref: "#/components/schemas/MediaFile"

    PaginatedKnowledgeAssets:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/KnowledgeAsset"
        next_cursor:
          type: string
          nullable: true

    KnowledgeAssetEnvelope:
      type: object
      properties:
        asset:
          $ref: "#/components/schemas/KnowledgeAsset"

    KnowledgeAssetDetail:
      type: object
      properties:
        asset:
          $ref: "#/components/schemas/KnowledgeAsset"
        versions:
          type: array
          items:
            $ref: "#/components/schemas/MediaVersion"

    UpsertKnowledgeAssetRequest:
      type: object
      required: [title, classification, lifecycle_area]
      properties:
        asset_id:
          type: string
          description: Omit to create a new asset
        title:
          type: string
        classification:
          $ref: "#/components/schemas/MediaClassification"
        lifecycle_area:
          type: string
        status:
          $ref: "#/components/schemas/KnowledgeAssetStatus"
        lifecycle_phases:
          type: array
          items:
            type: string
        primary_audience:
          type: array
          items:
            type: string
        owner:
          type: string
        source_references:
          type: array
          items:
            type: string
        manifest_path:
          type: string
        current_version:
          type: string
        storage_prefix:
          type: string
        review_notes:
          type: string
        required_reviewers:
          type: array
          items:
            type: string
        review:
          $ref: "#/components/schemas/KnowledgeAssetReview"

    RegisterMediaVersionRequest:
      type: object
      required: [version]
      properties:
        version:
          type: string
          description: Immutable — an existing version string answers 409
        status:
          $ref: "#/components/schemas/MediaReviewStatus"
        storage_prefix:
          type: string
        manifest_s3_key:
          type: string
        manifest_path:
          type: string
        release_notes:
          type: string
        supersedes_version:
          type: string
        source_commit:
          type: string
        files:
          type: array
          items:
            type: object
            properties:
              file_id:
                type: string
              role:
                $ref: "#/components/schemas/MediaFileRole"
              relative_path:
                type: string
              file_name:
                type: string
              content_type:
                type: string
              size_bytes:
                type: integer
              sha256:
                type: string
              language:
                type: string
              format:
                type: string
              storage_key:
                type: string

    MediaSignedUrlRequest:
      type: object
      properties:
        version:
          type: string
        file_id:
          type: string
        relative_path:
          type: string
        asset_id:
          type: string
          description: Required on the /partner/media/files/{fileId}/signed-url form
        project_id:
          type: string
          description: Required for client-confidential assets — proves engagement scope

    MediaSignedUrlResponse:
      type: object
      properties:
        signed_url:
          type: string
        expires_in:
          type: integer
        expires_at:
          type: string
          format: date-time
        file:
          $ref: "#/components/schemas/MediaFile"
        asset:
          $ref: "#/components/schemas/KnowledgeAsset"
        version:
          $ref: "#/components/schemas/MediaVersion"

    PublishDocumentRequest:
      type: object
      required: [title, file_name, size_bytes]
      properties:
        title:
          type: string
        file_name:
          type: string
        content_type:
          type: string
          default: application/octet-stream
        size_bytes:
          type: integer
          maximum: 536870912
        sha256:
          type: string
          description: Optional 64-char hex digest; when present, S3 enforces it on upload
        classification:
          $ref: "#/components/schemas/MediaClassification"
        engagement_id:
          type: string
          description: Links the document to an engagement via source_references
        notes:
          type: string

    PublishDocumentResponse:
      type: object
      properties:
        asset:
          $ref: "#/components/schemas/KnowledgeAsset"
        version:
          $ref: "#/components/schemas/MediaVersion"
        file:
          $ref: "#/components/schemas/MediaFile"
        upload_url:
          type: string
          description: Presigned S3 PUT for the file bytes
        upload_expires_in:
          type: integer
        upload_headers:
          type: object
          additionalProperties:
            type: string
          description: Headers the uploader MUST send with the PUT

    PublishCompleteResponse:
      type: object
      properties:
        asset:
          $ref: "#/components/schemas/KnowledgeAsset"
        version:
          type: object
          allOf:
            - $ref: "#/components/schemas/MediaVersion"
          nullable: true
        file:
          $ref: "#/components/schemas/MediaFile"

    EngagementArtifactType:
      type: string
      enum:
        [
          PILOT_CHARTER,
          MANDATE_DESIGN,
          VERIFICATION_PLAN,
          EVIDENCE_PACK,
          PILOT_GATE_DECISION,
          OPERATIONS_HANDOVER,
        ]

    EngagementArtifact:
      type: object
      properties:
        artifact_id:
          type: string
        tenant_id:
          type: string
        engagement_id:
          type: string
        artifact_type:
          $ref: "#/components/schemas/EngagementArtifactType"
        status:
          type: string
          enum: [DRAFT, IN_REVIEW, APPROVED, SUPERSEDED]
        version:
          type: integer
        payload:
          type: object
          additionalProperties: true
        review_task_id:
          type: string
          nullable: true
        approved_by:
          type: string
          nullable: true
        approved_at:
          type: string
          format: date-time
          nullable: true
        created_by:
          type: string
        created_at:
          type: string
          format: date-time
        updated_by:
          type: string
        updated_at:
          type: string
          format: date-time

    EngagementArtifactEnvelope:
      type: object
      properties:
        artifact:
          type: object
          allOf:
            - $ref: "#/components/schemas/EngagementArtifact"
          nullable: true
          description: Null when the artifact has not been authored yet

    PartnerReviewTask:
      type: object
      description: Review task created when an artifact is submitted
      additionalProperties: true
      properties:
        task_id:
          type: string
        tenant_id:
          type: string
        subject_id:
          type: string
        reviewer_role:
          type: string
        status:
          type: string
        created_at:
          type: string
          format: date-time
    # ── Organisation model ────────────────────────────────────
    OrgUnitOwner:
      type: object
      required: [subject_id]
      properties:
        subject_id:
          type: string
          description: Subject identifier of the accountable person
        display_name:
          type: string
          description: Display name, so reporting needs no directory lookup
        email:
          type: string
          format: email
          description: Contact address for escalation
        role_title:
          type: string
          description: The customer's own role label
          example: Head of Digital Operations

    OrgUnit:
      type: object
      required:
        [org_unit_id, tenant_id, kind, name, owner, status, path, depth, created_at, updated_at]
      properties:
        org_unit_id:
          type: string
          description: Unique identifier, "org_" followed by a ULID
        tenant_id:
          type: string
        parent_id:
          type: string
          description: Parent unit; absent for the root unit
        kind:
          type: string
          enum: [root, division, department, team]
        name:
          type: string
        description:
          type: string
        owner:
          $ref: "#/components/schemas/OrgUnitOwner"
        status:
          type: string
          enum: [ACTIVE, ARCHIVED]
        path:
          type: array
          description: |
            Materialised ancestor path from the root to this unit, inclusive.
            Stored rather than derived so a subtree resolves in one read.
          items:
            type: string
        depth:
          type: integer
          minimum: 0
          maximum: 3
          description: Depth in the hierarchy; the root unit is 0
        tags:
          type: object
          additionalProperties:
            type: string
          description: Reporting dimensions the customer defines themselves
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CreateOrgUnitRequest:
      type: object
      required: [name, kind, owner]
      properties:
        name:
          type: string
        kind:
          type: string
          enum: [root, division, department, team]
        owner:
          $ref: "#/components/schemas/OrgUnitOwner"
        parent_id:
          type: string
          description: Required for every unit except the root
        description:
          type: string
        tags:
          type: object
          additionalProperties:
            type: string

    UpdateOrgUnitRequest:
      type: object
      description: |
        `kind`, `parent_id`, `path` and `depth` are deliberately absent — they
        are immutable after creation.
      properties:
        name:
          type: string
        description:
          type: string
        owner:
          $ref: "#/components/schemas/OrgUnitOwner"
        tags:
          type: object
          additionalProperties:
            type: string

    OrgHierarchyError:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
        code:
          type: string
          enum:
            - PARENT_NOT_FOUND
            - PARENT_ARCHIVED
            - ROOT_ALREADY_EXISTS
            - ROOT_REQUIRES_NO_PARENT
            - NON_ROOT_REQUIRES_PARENT
            - MAX_DEPTH_EXCEEDED
            - INVALID_KIND_FOR_DEPTH
            - CYCLE_DETECTED
            - HAS_ACTIVE_CHILDREN
          description: |
            Machine-readable reason, so a client can tell a fixable input error
            from a structural conflict without parsing prose.

    RiskProfile:
      type: object
      description: |
        Risk classification across the four axes of the Cloud Security Alliance
        Capabilities-Based Risk Assessment model: system criticality, agent
        autonomy, access permissions and impact radius.

        The model is adopted rather than invented. An assessor can look up a
        published model; a score whose derivation exists only in one vendor's
        code cannot be audited.
      required:
        [
          system_criticality,
          agent_autonomy,
          access_permissions,
          impact_radius,
          classified_by,
          classified_at,
        ]
      properties:
        system_criticality:
          type: string
          enum: [low, moderate, high, mission_critical]
          description: How critical the touched systems are to the business
        agent_autonomy:
          type: string
          enum:
            - human_approves_each_action
            - human_approves_exceptions
            - human_reviews_after
            - fully_autonomous
          description: How much the agent decides without a human in the loop
        access_permissions:
          type: string
          enum: [read_only, scoped_write, broad_write, administrative]
          description: The breadth of what the delegated authority can reach
        impact_radius:
          type: string
          enum: [single_record, single_process, cross_department, external_or_regulatory]
          description: How far the consequences of a wrong action extend
        classified_by:
          type: string
          description: |
            Subject who made the classification. Required: an unattributed
            rating is not evidence, and both NIST AI RMF GOVERN 2.1 and
            ISO/IEC 42001 clause 5.3 ask who is accountable for the judgment.
        classified_at:
          type: string
          format: date-time
        rationale:
          type: string
          description: Justification, surfaced in management reporting
        eu_ai_act_high_risk_declared:
          type: boolean
          description: |
            Deployer declaration, not a computed verdict. High-risk status under
            the EU AI Act follows from Annex III context and intended purpose,
            not from attributes a platform can observe.

    ScoredRiskProfile:
      allOf:
        - $ref: "#/components/schemas/RiskProfile"
        - type: object
          required: [composite_score, tier]
          properties:
            composite_score:
              type: integer
              minimum: 1
              maximum: 256
              description: |
                Product of the four axis weights. Multiplicative rather than
                additive so that a single extreme axis cannot be averaged away
                by three benign ones.
            tier:
              type: string
              enum: [low, moderate, high, critical]
    Rate:
      type: object
      description: |
        A ratio that carries the counts it was derived from.

        `value` is `null` when `denominator` is zero. A rate over an empty
        population is undefined, not zero — reporting `0` would let a tenant with
        no activity appear compliant rather than unobserved.
      required:
        - value
        - numerator
        - denominator
      properties:
        value:
          type: number
          nullable: true
          minimum: 0
          maximum: 1
          example: 0.25
        numerator:
          type: integer
          minimum: 0
          example: 1
        denominator:
          type: integer
          minimum: 0
          example: 4

    PostureCoverage:
      type: object
      description: |
        What the aggregate actually observed. Read this before reading the
        figures: `truncated` distinguishes a complete window from a partial one.
      required:
        - window_start
        - window_end
        - events_scanned
        - pages_scanned
        - truncated
      properties:
        window_start:
          type: string
          format: date-time
        window_end:
          type: string
          format: date-time
        events_scanned:
          type: integer
          description: Relevant events aggregated.
        pages_scanned:
          type: integer
          description: Index pages read. Reaching the cap implies truncation.
        truncated:
          type: boolean
          description: |
            True when the page cap was reached before the window was exhausted.
            The aggregate then covers the most recent part of the window only.
        earliest_event:
          type: string
          format: date-time
        latest_event:
          type: string
          format: date-time

    BoundaryAdherence:
      type: object
      description: Whether agents stayed inside their granted boundaries.
      required:
        - verification_allowed
        - verification_denied
        - verification_total
        - denial_rate
        - mandates_with_denials
      properties:
        verification_allowed:
          type: integer
        verification_denied:
          type: integer
        verification_total:
          type: integer
        denial_rate:
          allOf:
            - $ref: "#/components/schemas/Rate"
          description: |
            Denied over total verifications. Not a quality score in either
            direction: a rising rate can mean tightened mandates or a
            misconfigured agent, and only the per-unit breakdown distinguishes
            the two.
        mandates_with_denials:
          type: integer
          description: |
            Distinct mandates with at least one denial. Reported alongside the
            rate because one agent denied a thousand times and a thousand agents
            denied once are different problems with the same rate.

    HumanOversight:
      type: object
      description: Whether humans were meaningfully in the loop.
      required:
        - consent_requested
        - consent_approved
        - consent_rejected
        - consent_auto_approved
        - consent_expired
        - escalations_triggered
        - human_review_rate
        - rejection_rate
      properties:
        consent_requested:
          type: integer
        consent_approved:
          type: integer
        consent_rejected:
          type: integer
        consent_auto_approved:
          type: integer
        consent_expired:
          type: integer
        escalations_triggered:
          type: integer
        human_review_rate:
          allOf:
            - $ref: "#/components/schemas/Rate"
          description: |
            Human decisions over all resolved consent requests. Expired requests
            count as resolved: an organisation that lets nine of ten requests
            lapse has weak oversight, and excluding them would report perfect
            oversight instead.
        rejection_rate:
          allOf:
            - $ref: "#/components/schemas/Rate"
          description: |
            Rejections over human decisions. A rate of zero over a meaningful
            sample suggests rubber-stamping rather than review.

    ResponseLatency:
      type: object
      description: |
        Time from first boundary violation to containment, per mandate.

        Mandates with denials that were never contained are counted separately
        rather than omitted. Excluding them would improve the reported latency in
        proportion to how badly the organisation actually responded.
      required:
        - sample_size
        - denials_without_containment
        - median_seconds
        - p90_seconds
      properties:
        sample_size:
          type: integer
          description: Mandates with a denial followed by suspension or revocation.
        denials_without_containment:
          type: integer
          description: Mandates with denials and no containment inside the window.
        median_seconds:
          type: number
          nullable: true
        p90_seconds:
          type: number
          nullable: true

    RiskDistribution:
      type: object
      description: |
        Distribution of classified delegations across risk bands, derived from
        the multiplicative composite score of the four risk axes.
      required:
        - low
        - moderate
        - high
        - critical
        - unclassified
        - invalid
        - classification_coverage
        - eu_ai_act_high_risk_declared
      properties:
        low:
          type: integer
        moderate:
          type: integer
        high:
          type: integer
        critical:
          type: integer
        unclassified:
          type: integer
          description: Mandates carrying no risk profile.
        invalid:
          type: integer
          description: |
            Mandates whose risk profile cannot be scored because an axis holds an
            unrecognised value. Reported separately from `unclassified` — a
            missing classification is an unfinished task, a malformed one a
            data-quality defect — and never folded into `low`.
        classification_coverage:
          allOf:
            - $ref: "#/components/schemas/Rate"
          description: Scoreable profiles over all mandates.
        eu_ai_act_high_risk_declared:
          type: integer
          description: |
            Mandates the deployer declared in scope of the AI Act's high-risk
            regime. A declaration, not a computed verdict: high-risk status
            follows from Annex III context and intended purpose, which no
            technical attribute determines.

    OrgUnitPosture:
      type: object
      description: |
        Per-unit breakdown, ordered worst-first.

        Exists because aggregates hide outliers: one high-privilege delegation
        misbehaving disappears into a tenant-wide average.
      required:
        - unit_id
        - unit_name
        - mandate_count
        - active_mandate_count
        - classified_mandate_count
        - verification_denied
        - escalations_triggered
      properties:
        unit_id:
          type: string
          nullable: true
          description: |
            Null for the bucket of mandates with no organisational assignment.
            Reported rather than hidden — unassigned delegations are themselves a
            governance gap.
        unit_name:
          type: string
        owner_subject_id:
          type: string
          description: Accountable owner of the unit.
        mandate_count:
          type: integer
        active_mandate_count:
          type: integer
        classified_mandate_count:
          type: integer
        peak_risk_band:
          type: string
          enum: [low, moderate, high, critical]
          description: |
            Worst band in the unit, not the average. Undefined when no profile in
            the unit could be scored.
        verification_denied:
          type: integer
        escalations_triggered:
          type: integer

    PostureCaveat:
      type: object
      description: |
        A condition that makes an indicator misleading if read at face value.
        Part of the answer, not decoration: a client that renders the figures and
        drops the caveats will mislead its reader.
      required:
        - code
        - message
      properties:
        code:
          type: string
          enum:
            - WINDOW_TRUNCATED
            - NO_VERIFICATIONS
            - NO_CONSENT_DECISIONS
            - LOW_RESPONSE_SAMPLE
            - UNASSIGNED_MANDATES
            - LOW_CLASSIFICATION_COVERAGE
            - INVALID_RISK_PROFILES
            - MANDATE_SCAN_TRUNCATED
        message:
          type: string

    GovernancePosture:
      type: object
      required:
        - tenant_id
        - generated_at
        - coverage
        - boundary_adherence
        - human_oversight
        - response_latency
        - risk_distribution
        - org_breakdown
        - caveats
      properties:
        tenant_id:
          type: string
        generated_at:
          type: string
          format: date-time
          description: |
            Generation time, so a reader can judge freshness. An undated
            governance figure invites the assumption that it is current.
        coverage:
          $ref: "#/components/schemas/PostureCoverage"
        boundary_adherence:
          $ref: "#/components/schemas/BoundaryAdherence"
        human_oversight:
          $ref: "#/components/schemas/HumanOversight"
        response_latency:
          $ref: "#/components/schemas/ResponseLatency"
        risk_distribution:
          $ref: "#/components/schemas/RiskDistribution"
        org_breakdown:
          type: array
          items:
            $ref: "#/components/schemas/OrgUnitPosture"
        caveats:
          type: array
          items:
            $ref: "#/components/schemas/PostureCaveat"
