openapi: 3.0.3
info:
  title: AppsCert Change Engine API
  version: v1
  description: |
    Contract for the versioned change-engine and workload-authentication routes
    under `/api/v1`. This document intentionally does not describe the separate
    dashboard, certificate, or legacy CI APIs.

    Protected change-engine routes accept either an authenticated human session
    with current organization membership and the required `resource:read` or
    `resource:write` authorization, or an opaque workload Bearer token. Workload
    tokens are organization-bound and are additionally checked for their exact
    scopes and repository/environment resource bindings. For protected operations,
    the OpenAPI `WorkloadBearerAuth` scheme describes the workload-token option;
    `x-human-session-auth: true` and the operation descriptions identify the
    human-session alternative. The Auth.js session cookie name varies by runtime
    and is therefore not represented as a fixed cookie security scheme.

    `POST /api/v1/auth/exchange` is a GitHub Actions OIDC assertion exchange,
    not human SSO or an API-key exchange. Its `?op=introspect` variant accepts
    the opaque workload token in the JSON body, never in a URL. Requests use
    canonical snake_case field names; some handlers also accept camelCase input
    aliases. Response field names match the handlers (the introspection helper
    currently returns camelCase fields).
  contact:
    name: AppsCert Support
    url: https://appscert.com/docs
    email: support@appscert.com
  license:
    name: Proprietary
    url: https://appscert.com/terms

servers:
  - url: https://appscert.com
    description: AppsCert public origin
  - url: http://localhost:3000
    description: Local development

tags:
  - name: Resources
    description: Organization-scoped external resources and consumer bindings.
  - name: Plans
    description: Idempotent, plan-only change requests; planning does not execute providers.
  - name: Operations
    description: Durable operation registration, polling, approval, revocation, and cancellation.
  - name: Receipts
    description: Idempotent consumer-version evidence submissions.
  - name: Workload authentication
    description: GitHub Actions OIDC exchange and opaque workload-token introspection.

paths:
  /api/v1/resources:
    get:
      operationId: listChangeResources
      tags: [Resources]
      summary: List resources in an organization
      description: |
        Returns the organization's registered external resources. A human caller
        needs current organization membership with `resource:read`. A workload
        caller needs `change:resource:read`, a token bound to this organization,
        and a consumer binding for the workload repository/environment; workload
        responses filter consumer references to that binding. `organization_id`
        is required and is not inferred from the caller.
      x-human-session-auth: true
      security:
        - WorkloadBearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OrganizationIdQuery'
      responses:
        '200':
          description: Resources visible to this authorized caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResourceListResponse'
              examples:
                resourceList:
                  summary: Synthetic resource and consumer inventory
                  value:
                    resources:
                      - id: res_example
                        organization_id: org_example
                        external_account: apple_pay
                        external_id: merchant.example
                        class: payment_processing
                        region: null
                        metadata:
                          note: Synthetic inventory example
                        consumers:
                          - id: con_example
                            kind: github
                            ref: repo:example/web:production
                            desired_version: release-2026-11
                            observed_version: release-2026-10
                            observed_at: '2026-11-07 10:30:00'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: registerChangeResource
      tags: [Resources]
      summary: Register or update a resource and its consumers
      description: |
        Upserts resource metadata and any supplied consumer bindings atomically.
        A human caller needs current organization membership with `resource:write`;
        a workload caller needs `change:resource:write`, an organization-bound
        token, and matching repository/environment consumer references. Workloads
        may bind only GitHub consumers and cannot request `automation_owner`;
        selecting `change_engine` ownership is a human-approved opt-in. Metadata
        is restricted to the documented allowlisted, non-secret shape.
      x-human-session-auth: true
      security:
        - WorkloadBearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResourceRegisterRequest'
            examples:
              registerResource:
                summary: Synthetic resource registration
                value:
                  organization_id: org_example
                  external_account: apple_pay
                  external_id: merchant.example
                  class: payment_processing
                  region: production
                  metadata:
                    note: Synthetic registration example
                  consumers:
                    - kind: github
                      ref: repo:example/web:production
                      desired_version: release-2026-11
      responses:
        '201':
          description: Resource and optional automation-owner state after upsert.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResourceRegisterResponse'
              example:
                resource:
                  id: res_example
                  organization_id: org_example
                  external_account: apple_pay
                  external_id: merchant.example
                  class: payment_processing
                  region: production
                  metadata:
                    note: Synthetic registration example
                automation_owner: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/plans:
    post:
      operationId: createChangePlan
      tags: [Plans]
      summary: Create or replay a change plan
      description: |
        Creates an intent-only plan; this endpoint does not create a job or call
        a provider. Human callers need current organization `resource:write`
        authorization. A workload caller must be bound to the organization and
        resource and have `change:plan`, `change:*`, or every
        `change:{resource-class}:{capability}` scope required by the plan steps.
        Supply an idempotency key in the `Idempotency-Key` header or the
        `idempotency_key` body property (the header takes precedence). The key is
        scoped by organization and actor; replaying it with a different intent
        returns `409 idempotency_conflict`.
      x-human-session-auth: true
      security:
        - WorkloadBearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlanCreateRequest'
            examples:
              createPlan:
                summary: Synthetic plan-only request
                value:
                  organization_id: org_example
                  external_account: apple_pay
                  external_id: merchant.example
                  class: payment_processing
                  idempotency_key: plan-example-1
                  steps:
                    - kind: verify
                      requires:
                        - class: payment_processing
                          external_account: apple_pay
                          capability: verify
                      preconditions:
                        environment: production
                  metadata:
                    correlation_id: req_example
      responses:
        '202':
          description: Plan created or replayed; no execution job was started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanCreateResult'
              examples:
                registeredPlan:
                  summary: Newly registered synthetic plan
                  value:
                    api_version: v1
                    outcome: registered
                    plan:
                      id: plan_example
                      status: planned
                      intent_hash: synthetic-intent-hash
                    resource_id: res_example
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'

  /api/v1/operations:
    post:
      operationId: executeChangePlan
      tags: [Operations]
      summary: Register or replay an operation for an approved plan
      description: |
        Registers a durable job for a matching plan and returns `202 Accepted`;
        acceptance or `pending` status does not mean that provider work succeeded.
        `approved` must be exactly `true`, and `plan_intent_hash` must match the
        stored plan. Human callers need current organization `resource:write`
        authorization. Workload callers need every capability scope required
        by the plan and a repository/environment
        binding to its resource. Idempotency is accepted in the header or body,
        scoped by organization and actor; a changed intent with the same key
        returns `409 idempotency_conflict`.
      x-human-session-auth: true
      security:
        - WorkloadBearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OperationCreateRequest'
            examples:
              executeApprovedPlan:
                summary: Synthetic approved-plan execution request
                value:
                  plan_id: plan_example
                  plan_intent_hash: synthetic-intent-hash
                  approved: true
                  operation_type: verify
                  idempotency_key: operation-example-1
      responses:
        '202':
          description: Durable operation registered or replayed; poll its status separately.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperationCreateResult'
              examples:
                registeredOperation:
                  summary: Registered synthetic operation
                  value:
                    api_version: v1
                    outcome: registered
                    operation:
                      id: operation_example
                      status: pending
                    plan:
                      id: plan_example
                      status: queued
                      intent_hash: synthetic-intent-hash
                    resource_id: res_example
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /api/v1/operations/{id}:
    parameters:
      - $ref: '#/components/parameters/OperationIdPath'
    get:
      operationId: getChangeOperation
      tags: [Operations]
      summary: Read an operation and visible evidence
      description: |
        Returns the operation, its plan and resource, steps, and evidence visible
        to the caller. A human caller needs current organization `resource:read`
        authorization. A workload caller needs `change:resource:read` and a
        binding to the operation's resource; evidence and consumer receipts are
        filtered to that workload's repository/environment. Authorization is
        re-evaluated against the operation's organization on each request.
      x-human-session-auth: true
      security:
        - WorkloadBearerAuth: []
      responses:
        '200':
          description: Operation detail and caller-visible evidence.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OperationDetailResponse'
              examples:
                operationDetail:
                  summary: Synthetic pending operation detail
                  value:
                    operation:
                      id: operation_example
                      organization_id: org_example
                      resource_id: res_example
                      plan_id: plan_example
                      type: verify
                      status: pending
                      attempts: 0
                      last_error: null
                      not_before: null
                      available_at: null
                    plan:
                      id: plan_example
                      status: queued
                      intent_hash: synthetic-intent-hash
                      created_by: user_example
                    resource:
                      id: res_example
                      external_account: apple_pay
                      external_id: merchant.example
                      class: payment_processing
                      region: production
                    steps:
                      - id: step_example
                        index: 0
                        status: pending
                        fence_epoch: 0
                        last_error: null
                        started_at: null
                        finished_at: null
                    evidence: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    post:
      operationId: manageChangeOperation
      tags: [Operations]
      summary: Approve, revoke approval, or cancel an operation
      description: |
        `action` is `approve`, `revoke`, or `cancel`. Human callers need current
        organization `resource:write` authorization; workload callers need
        the plan's required capability scopes and a
        binding to the resource. Approve/revoke append plan-approval evidence and
        do not directly advance the job lifecycle. Cancel only succeeds while the
        job is still queued; after execution starts it returns `409
        operation_not_cancellable`. An optional reason is recorded as evidence.
      x-human-session-auth: true
      security:
        - WorkloadBearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OperationActionRequest'
            examples:
              cancel:
                summary: Cancel before execution starts
                value:
                  action: cancel
                  reason: Superseded plan
              approve:
                summary: Record plan approval
                value:
                  action: approve
                  reason: Reviewed synthetic plan
              revoke:
                summary: Revoke plan approval
                value:
                  action: revoke
                  reason: Approval withdrawn
      responses:
        '202':
          description: Action accepted, registered, or replayed.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/OperationCancelResult'
                  - $ref: '#/components/schemas/OperationApprovalResult'
                  - $ref: '#/components/schemas/OperationRevocationResult'
              examples:
                cancelledOperation:
                  summary: Synthetic queued operation cancellation
                  value:
                    api_version: v1
                    outcome: registered
                    operation:
                      id: operation_example
                      status: cancelled
                    plan:
                      id: plan_example
                      status: cancelled
                      intent_hash: synthetic-intent-hash
                    resource_id: res_example
                    evidence_id: evidence_example
                approvedPlan:
                  summary: Synthetic plan approval evidence
                  value:
                    api_version: v1
                    outcome: registered
                    approval:
                      action: approve
                      grant_id: evidence_grant_example
                      plan_id: plan_example
                      plan_intent_hash: synthetic-intent-hash
                      body_scope_hash: synthetic-scope-hash
                      capabilities: [verify]
                      safe_only: false
                      approver_ref: user_example
                      reason: Reviewed synthetic plan
                      observed_at: '2026-11-07 10:30:00'
                    operation:
                      id: operation_example
                      status: pending
                    plan:
                      id: plan_example
                      status: planned
                      intent_hash: synthetic-intent-hash
                    resource_id: res_example
                revokedPlanApproval:
                  summary: Synthetic approval revocation evidence
                  value:
                    api_version: v1
                    outcome: registered
                    approval:
                      action: revoke
                      revoke_id: evidence_revoke_example
                      plan_id: plan_example
                      plan_intent_hash: synthetic-intent-hash
                      body_scope_hash: synthetic-scope-hash
                      revoker_ref: user_example
                      reason: Approval withdrawn
                      observed_at: '2026-11-07 10:31:00'
                    operation:
                      id: operation_example
                      status: pending
                    plan:
                      id: plan_example
                      status: planned
                      intent_hash: synthetic-intent-hash
                    resource_id: res_example
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /api/v1/receipts:
    post:
      operationId: submitConsumerReceipt
      tags: [Receipts]
      summary: Record a consumer version receipt
      description: |
        Submits an idempotent receipt for a registered consumer. Human callers
        need current organization `resource:write` authorization. Workload callers
        need `change:receipt`, an organization-bound token, and a consumer
        reference matching their bound GitHub repository/environment. A receipt
        is accepted only when it matches the consumer's desired version and any
        supplied environment matches the reference. Stale or wrong-environment
        receipts still return `202` with `accepted: false` and a reason; they are
        historical evidence and do not update the observed version. The
        idempotency key is accepted in the header or body and scoped by
        organization and actor.
      x-human-session-auth: true
      security:
        - WorkloadBearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReceiptRequest'
            examples:
              receipt:
                summary: Synthetic consumer receipt
                value:
                  organization_id: org_example
                  resource_id: res_example
                  operation_id: operation_example
                  consumer_kind: github
                  consumer_ref: repo:example/web:production
                  version: release-2026-11
                  environment: production
                  idempotency_key: receipt-example-1
                  metadata:
                    correlation_id: req_example
      responses:
        '202':
          description: Receipt recorded or replayed; stale receipts are historical evidence.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReceiptResult'
              examples:
                historicalReceipt:
                  summary: Synthetic stale receipt retained as historical evidence
                  value:
                    api_version: v1
                    outcome: registered
                    accepted: false
                    reason: stale_version
                    evidence_id: evidence_example
                    resource_id: res_example
                    operation_id: operation_example
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /api/v1/auth/exchange:
    post:
      operationId: exchangeOrIntrospectWorkloadToken
      tags: [Workload authentication]
      summary: Exchange a GitHub Actions assertion or introspect a workload token
      description: |
        With `op` omitted or set to `exchange` (the default), exchange the
        GitHub Actions OIDC JWT in `assertion` for a short-lived opaque token
        bound to a pre-registered workload identity. This is not human SSO and
        does not accept a dashboard API key. With `op=introspect`, put the
        opaque token in the JSON body as `token`; the route performs an
        RFC 7662-style check and returns `active: false` as a successful `200`
        result when the token is inactive. Do not put either assertion or token
        in a URL. Responses are not cached; token plaintext is returned only in
        the exchange success response.
      x-human-session-auth: false
      security: []
      parameters:
        - name: op
          in: query
          required: false
          description: Selects the body contract. Omit or use `exchange` for OIDC exchange; use `introspect` for token inspection.
          schema:
            type: string
            enum: [exchange, introspect]
            default: exchange
      requestBody:
        required: true
        description: |
          Send `{ "assertion": "…" }` when `op` is omitted or `exchange`;
          send `{ "token": "…" }` when `op=introspect`.
        content:
          application/json:
            schema:
              anyOf:
                - $ref: '#/components/schemas/ExchangeRequest'
                - $ref: '#/components/schemas/IntrospectionRequest'
            examples:
              exchange:
                summary: Synthetic GitHub Actions assertion placeholder
                value:
                  assertion: SYNTHETIC_OIDC_ASSERTION
              introspection:
                summary: Synthetic opaque token placeholder
                value:
                  token: SYNTHETIC_OPAQUE_TOKEN
      responses:
        '200':
          description: |
            Opaque workload token on exchange; introspection result when
            `op=introspect` (including inactive tokens).
          headers:
            Cache-Control:
              description: Always `no-store` for this sensitive response.
              schema:
                type: string
                example: no-store
            Pragma:
              description: Compatibility cache prevention header.
              schema:
                type: string
                example: no-cache
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/WorkloadTokenResponse'
                  - $ref: '#/components/schemas/IntrospectionActiveResponse'
                  - $ref: '#/components/schemas/IntrospectionInactiveResponse'
              examples:
                issuedToken:
                  summary: Synthetic opaque workload-token exchange result
                  value:
                    access_token: SYNTHETIC_OPAQUE_TOKEN
                    token_type: Bearer
                    expires_in: 600
                    issued_at: '2026-11-07T10:30:00.000Z'
                    scope: change:resource:read
                    actor: workload
                    token_id: token_example
                    identity_id: identity_example
                    org_id: org_example
                    repository: example/web
                    environment: production
                    issuer: https://token.actions.githubusercontent.com
                    jwks_url: https://token.actions.githubusercontent.com/.well-known/jwks
                activeToken:
                  summary: Active synthetic workload token
                  value:
                    active: true
                    identityId: identity_example
                    orgId: org_example
                    repository: example/web
                    environment: production
                    scopes: [change:resource:read]
                    expiresAt: '2026-11-07 10:40:00.000'
                inactiveToken:
                  summary: Inactive token (not an HTTP error)
                  value:
                    active: false
                    reasonCode: unknown_or_expired_token
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: OIDC assertion verification failed; introspection reports inactive tokens with `200` instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Assertion is valid but no executable workload identity binding matches.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalServerError'

components:
  securitySchemes:
    WorkloadBearerAuth:
      type: http
      scheme: bearer
      description: |
        Opaque, short-lived workload access token returned by
        `POST /api/v1/auth/exchange` (not a dashboard API key and not a JWT).
        Each token is bound to an organization, identity, repository/environment,
        and granted scopes. Individual operations additionally enforce the
        required scope and resource-consumer binding.

  parameters:
    OrganizationIdQuery:
      name: organization_id
      in: query
      required: true
      description: Organization identifier to authorize and list resources for.
      schema:
        type: string
        maxLength: 128
    OperationIdPath:
      name: id
      in: path
      required: true
      description: Operation identifier returned by an operation-registration response.
      schema:
        type: string
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional when `idempotency_key` is supplied in the JSON body. If both
        are supplied, the header takes precedence. Keys are scoped by organization
        and authenticated actor and are limited to 128 characters.
      schema:
        type: string
        maxLength: 128

  responses:
    BadRequest:
      description: Invalid JSON, missing or invalid required input, or unsupported action.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: invalid_request
              message: Invalid request
    Unauthorized:
      description: No authenticated human session was supplied. Invalid workload tokens are denied with `403`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: unauthorized
              message: Authentication required
    Forbidden:
      description: Organization authorization, workload scope, or resource binding is denied.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: permission_denied
              message: Access denied
    NotFound:
      description: Referenced plan, operation, resource, or related record was not found in the requested organization.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: not_found
              message: Operation not found
    Conflict:
      description: Intent/idempotency conflict, stale plan, invalid binding, or operation-state conflict.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: idempotency_conflict
              message: Idempotency key reused with a different intent
    PayloadTooLarge:
      description: The JSON request body exceeded the workload-auth route's size limit.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnsupportedMediaType:
      description: Workload-auth requests require an `application/json` content type.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: Workload-auth pre-verification or identity rate limit exceeded; `Retry-After` is returned.
      headers:
        Retry-After:
          description: Seconds until the rate-limit window resets.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: rate_limited
              message: Workload exchange rate limit exceeded
              retry_after: 60
    InternalServerError:
      description: Workload-auth verification or token exchange failed unexpectedly.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    NullValue:
      description: Explicit null alternative for nullable referenced response objects.
      type: object
      nullable: true
      enum: [null]
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ApiError'
    ApiError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: Stable machine-readable error code.
        message:
          type: string
        retry_after:
          type: integer
          description: Present on rate-limit errors; seconds until reset.
        status:
          type: string
          description: Current operation status included on some cancellation conflicts.
      additionalProperties: true

    ResourceClass:
      type: string
      enum: [payment_processing, merchant_identity, domain_binding]
    ExternalAccount:
      type: string
      enum: [apple_pay, github, psp, vault, dns_provider]
    Capability:
      type: string
      enum: [issue, deliver, prepare, activate, verify, revoke, observe]
    PlanStatus:
      type: string
      enum: [planned, awaiting_approval, queued, running, awaiting_external, reconciling, verified, failed, cancelled]
    OperationStatus:
      type: string
      enum: [pending, running, awaiting_external, reconciling, succeeded, failed, cancelled]
    OperationStepStatus:
      type: string
      enum: [pending, claimed, running, succeeded, failed, reconciling, cancelled]

    MetadataInput:
      description: |
        Metadata object accepted by write handlers. Keys are recursively limited
        to the documented allowlist; values may be scalar, null, arrays, or nested
        objects. The handler limits strings to 256 characters, arrays and object
        key counts to 32, nesting depth to four, and metadata to 2,000 nodes and
        32 KiB; credential-like material is rejected. Arrays cannot contain arrays.
      allOf:
        - $ref: '#/components/schemas/MetadataObject'
    MetadataObject:
      type: object
      nullable: true
      properties:
        actor_kind:
          $ref: '#/components/schemas/MetadataValue'
        actor_ref:
          $ref: '#/components/schemas/MetadataValue'
        correlation_id:
          $ref: '#/components/schemas/MetadataValue'
        environment:
          $ref: '#/components/schemas/MetadataValue'
        external_reference:
          $ref: '#/components/schemas/MetadataValue'
        note:
          $ref: '#/components/schemas/MetadataValue'
        reason:
          $ref: '#/components/schemas/MetadataValue'
        repository:
          $ref: '#/components/schemas/MetadataValue'
        request_id:
          $ref: '#/components/schemas/MetadataValue'
        source:
          $ref: '#/components/schemas/MetadataValue'
        version:
          $ref: '#/components/schemas/MetadataValue'
        workload_environment:
          $ref: '#/components/schemas/MetadataValue'
        workload_repository:
          $ref: '#/components/schemas/MetadataValue'
        metadata:
          $ref: '#/components/schemas/MetadataValue'
      additionalProperties: false
    MetadataValue:
      description: JSON metadata value accepted by the write handlers.
      anyOf:
        - type: string
          maxLength: 256
          nullable: true
        - type: number
          nullable: true
        - type: boolean
          nullable: true
        - type: array
          maxItems: 32
          nullable: true
          items:
            $ref: '#/components/schemas/MetadataArrayItem'
        - $ref: '#/components/schemas/MetadataObject'
    MetadataArrayItem:
      description: Array members may be scalar, null, or a nested metadata object; arrays cannot be nested in arrays.
      anyOf:
        - type: string
          maxLength: 256
          nullable: true
        - type: number
          nullable: true
        - type: boolean
          nullable: true
        - $ref: '#/components/schemas/MetadataObject'

    ResourceConsumer:
      type: object
      required: [id, kind, ref, desired_version, observed_version, observed_at]
      properties:
        id:
          type: string
        kind:
          type: string
        ref:
          type: string
        desired_version:
          type: string
          nullable: true
        observed_version:
          type: string
          nullable: true
        observed_at:
          type: string
          nullable: true
    ResourceConsumerInput:
      type: object
      required: [kind, ref]
      properties:
        kind:
          type: string
          maxLength: 64
          pattern: '^[A-Za-z0-9_.:-]+$'
          description: Workload callers may register only `github` consumers.
        ref:
          type: string
          maxLength: 220
        desired_version:
          type: string
          maxLength: 128
          nullable: true
    ResourceRow:
      type: object
      required: [id, organization_id, external_account, external_id, class, region, metadata, consumers]
      properties:
        id:
          type: string
        organization_id:
          type: string
        external_account:
          $ref: '#/components/schemas/ExternalAccount'
        external_id:
          type: string
        class:
          $ref: '#/components/schemas/ResourceClass'
        region:
          type: string
          nullable: true
        metadata:
          type: object
          additionalProperties: true
        consumers:
          type: array
          items:
            $ref: '#/components/schemas/ResourceConsumer'
    ResourceListResponse:
      type: object
      required: [resources]
      properties:
        resources:
          type: array
          items:
            $ref: '#/components/schemas/ResourceRow'
    ResourceRegisterRequest:
      type: object
      required: [organization_id, external_account, external_id, class]
      properties:
        organization_id:
          type: string
          maxLength: 160
        external_account:
          $ref: '#/components/schemas/ExternalAccount'
        external_id:
          type: string
          maxLength: 180
        class:
          $ref: '#/components/schemas/ResourceClass'
        region:
          type: string
          maxLength: 64
          nullable: true
        metadata:
          $ref: '#/components/schemas/MetadataInput'
        consumers:
          type: array
          items:
            $ref: '#/components/schemas/ResourceConsumerInput'
        automation_owner:
          type: string
          enum: [change_engine]
          nullable: true
          description: Human-approved opt-in only; workload callers are denied when this field is requested.
    AutomationOwner:
      type: object
      required: [kind, actor, acquired_at, transition_evidence_id]
      properties:
        kind:
          type: string
          enum: [change_engine, legacy_cron, manual]
        actor:
          type: string
        acquired_at:
          type: string
        transition_evidence_id:
          type: string
          nullable: true
    ResourceRegisterResponse:
      type: object
      required: [resource, automation_owner]
      properties:
        resource:
          type: object
          required: [id, organization_id, external_account, external_id, class, region, metadata]
          properties:
            id:
              type: string
            organization_id:
              type: string
            external_account:
              $ref: '#/components/schemas/ExternalAccount'
            external_id:
              type: string
            class:
              $ref: '#/components/schemas/ResourceClass'
            region:
              type: string
              nullable: true
            metadata:
              type: object
              additionalProperties: true
        automation_owner:
          anyOf:
            - $ref: '#/components/schemas/AutomationOwner'
            - $ref: '#/components/schemas/NullValue'

    StepRequirement:
      type: object
      required: [class, external_account, capability]
      properties:
        class:
          $ref: '#/components/schemas/ResourceClass'
        external_account:
          $ref: '#/components/schemas/ExternalAccount'
        capability:
          $ref: '#/components/schemas/Capability'
    PlanStep:
      type: object
      required: [kind, requires]
      properties:
        kind:
          type: string
          maxLength: 64
          pattern: '^[A-Za-z0-9_.:-]+$'
        requires:
          type: array
          minItems: 1
          maxItems: 1
          items:
            $ref: '#/components/schemas/StepRequirement'
        preconditions:
          $ref: '#/components/schemas/MetadataInput'
    PlanCreateRequest:
      type: object
      required: [organization_id, external_account, external_id, class, steps]
      properties:
        organization_id:
          type: string
          maxLength: 160
        external_account:
          $ref: '#/components/schemas/ExternalAccount'
        external_id:
          type: string
          maxLength: 180
        class:
          $ref: '#/components/schemas/ResourceClass'
        region:
          type: string
          maxLength: 64
          nullable: true
        steps:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/PlanStep'
        metadata:
          $ref: '#/components/schemas/MetadataInput'
        idempotency_key:
          type: string
          maxLength: 128
          description: Optional alternative to the `Idempotency-Key` header.
    PlanSummary:
      type: object
      required: [id, status, intent_hash]
      properties:
        id:
          type: string
        status:
          $ref: '#/components/schemas/PlanStatus'
        intent_hash:
          type: string
    PlanCreateResult:
      type: object
      required: [api_version, outcome, plan, resource_id]
      properties:
        api_version:
          type: string
          enum: [v1]
        outcome:
          type: string
          enum: [registered, replay]
        plan:
          $ref: '#/components/schemas/PlanSummary'
        resource_id:
          type: string

    OperationCreateRequest:
      type: object
      required: [plan_id, plan_intent_hash, approved]
      properties:
        plan_id:
          type: string
          maxLength: 128
        plan_intent_hash:
          type: string
          maxLength: 128
        approved:
          type: boolean
          enum: [true]
        operation_type:
          type: string
          maxLength: 64
          pattern: '^[A-Za-z0-9_.:-]+$'
          description: Defaults to the first plan-step kind when omitted.
        idempotency_key:
          type: string
          maxLength: 128
          description: Optional alternative to the `Idempotency-Key` header.
    OperationSummary:
      type: object
      required: [id, status]
      properties:
        id:
          type: string
        status:
          $ref: '#/components/schemas/OperationStatus'
    OperationCreateResult:
      type: object
      required: [api_version, outcome, operation, plan, resource_id]
      properties:
        api_version:
          type: string
          enum: [v1]
        outcome:
          type: string
          enum: [registered, replay]
        operation:
          $ref: '#/components/schemas/OperationSummary'
        plan:
          $ref: '#/components/schemas/PlanSummary'
        resource_id:
          type: string

    OperationDetail:
      type: object
      required: [id, organization_id, resource_id, plan_id, type, status, attempts, last_error, not_before, available_at]
      properties:
        id:
          type: string
        organization_id:
          type: string
        resource_id:
          type: string
        plan_id:
          type: string
          nullable: true
        type:
          type: string
        status:
          $ref: '#/components/schemas/OperationStatus'
        attempts:
          type: integer
        last_error:
          type: string
          nullable: true
        not_before:
          type: string
          nullable: true
        available_at:
          type: string
          nullable: true
    PlanDetail:
      type: object
      required: [id, status, intent_hash, created_by]
      properties:
        id:
          type: string
        status:
          $ref: '#/components/schemas/PlanStatus'
        intent_hash:
          type: string
        created_by:
          type: string
          nullable: true
    ResourceReference:
      type: object
      required: [id, external_account, external_id, class, region]
      properties:
        id:
          type: string
        external_account:
          $ref: '#/components/schemas/ExternalAccount'
        external_id:
          type: string
        class:
          $ref: '#/components/schemas/ResourceClass'
        region:
          type: string
          nullable: true
    OperationStep:
      type: object
      required: [id, index, status, fence_epoch, last_error, started_at, finished_at]
      properties:
        id:
          type: string
        index:
          type: integer
        status:
          $ref: '#/components/schemas/OperationStepStatus'
        fence_epoch:
          type: integer
        last_error:
          type: string
          nullable: true
        started_at:
          type: string
          nullable: true
        finished_at:
          type: string
          nullable: true
    Evidence:
      type: object
      required: [id, type, source, observed_at, observed_by, payload]
      properties:
        id:
          type: string
        type:
          type: string
        source:
          type: string
        observed_at:
          type: string
        observed_by:
          type: string
          nullable: true
        payload:
          type: object
          additionalProperties: true
    OperationDetailResponse:
      type: object
      required: [operation, plan, resource, steps, evidence]
      properties:
        operation:
          $ref: '#/components/schemas/OperationDetail'
        plan:
          anyOf:
            - $ref: '#/components/schemas/PlanDetail'
            - $ref: '#/components/schemas/NullValue'
        resource:
          $ref: '#/components/schemas/ResourceReference'
        steps:
          type: array
          items:
            $ref: '#/components/schemas/OperationStep'
        evidence:
          type: array
          items:
            $ref: '#/components/schemas/Evidence'

    OperationActionRequest:
      type: object
      required: [action]
      properties:
        action:
          type: string
          enum: [cancel, approve, revoke]
        reason:
          type: string
          maxLength: 120
          pattern: '^[A-Za-z0-9_.: /-]+$'
          nullable: true
    OperationCancelResult:
      type: object
      required: [api_version, outcome, operation, plan, resource_id, evidence_id]
      properties:
        api_version:
          type: string
          enum: [v1]
        outcome:
          type: string
          enum: [registered, replay]
        operation:
          $ref: '#/components/schemas/OperationSummary'
        plan:
          anyOf:
            - $ref: '#/components/schemas/PlanSummary'
            - $ref: '#/components/schemas/NullValue'
        resource_id:
          type: string
        evidence_id:
          type: string
          nullable: true
    OperationApproval:
      type: object
      required: [action, grant_id, plan_id, plan_intent_hash, body_scope_hash, capabilities, safe_only, approver_ref, reason, observed_at]
      properties:
        action:
          type: string
          enum: [approve]
        grant_id:
          type: string
        plan_id:
          type: string
        plan_intent_hash:
          type: string
        body_scope_hash:
          type: string
        capabilities:
          type: array
          items:
            $ref: '#/components/schemas/Capability'
        safe_only:
          type: boolean
          enum: [false]
        approver_ref:
          type: string
        reason:
          type: string
          maxLength: 120
          nullable: true
        observed_at:
          type: string
    OperationRevocation:
      type: object
      required: [action, revoke_id, plan_id, plan_intent_hash, body_scope_hash, revoker_ref, reason, observed_at]
      properties:
        action:
          type: string
          enum: [revoke]
        revoke_id:
          type: string
        plan_id:
          type: string
        plan_intent_hash:
          type: string
        body_scope_hash:
          type: string
        revoker_ref:
          type: string
        reason:
          type: string
          nullable: true
        observed_at:
          type: string
    OperationApprovalResult:
      type: object
      required: [api_version, outcome, approval, operation, plan, resource_id]
      properties:
        api_version:
          type: string
          enum: [v1]
        outcome:
          type: string
          enum: [registered]
        approval:
          $ref: '#/components/schemas/OperationApproval'
        operation:
          $ref: '#/components/schemas/OperationSummary'
        plan:
          $ref: '#/components/schemas/PlanSummary'
        resource_id:
          type: string
    OperationRevocationResult:
      type: object
      required: [api_version, outcome, approval, operation, plan, resource_id]
      properties:
        api_version:
          type: string
          enum: [v1]
        outcome:
          type: string
          enum: [registered]
        approval:
          $ref: '#/components/schemas/OperationRevocation'
        operation:
          $ref: '#/components/schemas/OperationSummary'
        plan:
          $ref: '#/components/schemas/PlanSummary'
        resource_id:
          type: string

    ReceiptRequest:
      type: object
      required: [organization_id, resource_id, consumer_kind, consumer_ref, version]
      properties:
        organization_id:
          type: string
          maxLength: 160
        resource_id:
          type: string
          maxLength: 128
        operation_id:
          type: string
          maxLength: 128
          nullable: true
        consumer_kind:
          type: string
          maxLength: 64
          pattern: '^[A-Za-z0-9_.:-]+$'
        consumer_ref:
          type: string
          maxLength: 220
        version:
          type: string
          maxLength: 128
        environment:
          type: string
          maxLength: 64
          nullable: true
        metadata:
          $ref: '#/components/schemas/MetadataInput'
        idempotency_key:
          type: string
          maxLength: 128
          description: Optional alternative to the `Idempotency-Key` header.
    ReceiptResult:
      type: object
      required: [api_version, outcome, accepted, reason, evidence_id, resource_id, operation_id]
      properties:
        api_version:
          type: string
          enum: [v1]
        outcome:
          type: string
          enum: [registered, replay]
        accepted:
          type: boolean
        reason:
          type: string
          nullable: true
        evidence_id:
          type: string
          nullable: true
        resource_id:
          type: string
        operation_id:
          type: string
          nullable: true

    ExchangeRequest:
      type: object
      required: [assertion]
      properties:
        assertion:
          type: string
          description: GitHub Actions OIDC JWT assertion; limited to 8 KiB by the handler.
        org_id:
          type: string
          description: Optional expected organization selector used when verifying the assertion binding.
        identity_id:
          type: string
          description: Optional expected workload identity selector used when verifying the assertion binding.
    IntrospectionRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: Opaque workload token to inspect; sent only in the body and limited to 512 bytes.
    WorkloadTokenResponse:
      type: object
      required: [access_token, token_type, expires_in, issued_at, scope, actor, token_id, identity_id, org_id, repository, environment, issuer, jwks_url]
      properties:
        access_token:
          type: string
          description: Opaque token plaintext, returned once by the exchange endpoint.
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Token lifetime in seconds.
        issued_at:
          type: string
          format: date-time
        scope:
          type: string
          description: Granted scopes as a comma-separated string.
        actor:
          type: string
          enum: [workload]
        token_id:
          type: string
        identity_id:
          type: string
        org_id:
          type: string
        repository:
          type: string
        environment:
          type: string
          nullable: true
        issuer:
          type: string
          format: uri
        jwks_url:
          type: string
          format: uri
    IntrospectionActiveResponse:
      type: object
      required: [active, identityId, orgId, repository, environment, scopes, expiresAt]
      properties:
        active:
          type: boolean
          enum: [true]
        identityId:
          type: string
        orgId:
          type: string
        repository:
          type: string
        environment:
          type: string
          nullable: true
        scopes:
          type: array
          items:
            type: string
        expiresAt:
          type: string
    IntrospectionInactiveResponse:
      type: object
      required: [active, reasonCode]
      properties:
        active:
          type: boolean
          enum: [false]
        reasonCode:
          type: string
          enum: [unknown_or_expired_token, identity_revoked, org_revoked, binding_drift, scope_drift]
