openapi: 3.1.0

info:
  title: Sigilbase API
  version: 1.0.0
  summary: Append-only, tamper-evident audit log ingestion and verification API.
  description: |
    The Sigilbase HTTP API: append events to tamper-evident streams, read them
    back, fetch inclusion proofs, and obtain the public signing keys used to
    verify checkpoints.

    This document is the contract, maintained by hand as the source of truth.
    The `version` above tracks the API, not the application. Breaking changes
    to `/api/v1` are forbidden by policy: fields may be added, but existing
    fields, semantics, and error shapes will not change or disappear. A change
    that cannot honour this policy ships as a new version prefix.

    Authentication uses an API key as a bearer token. Keys have the form
    `sgb_{key_id}.{secret}`: the part before the final dot is the public key
    id, the part after it is the secret. Examples in this document use the
    deliberately fake key `sgb_example.xxxx`.

    Requests are limited to 60 per minute per API key. Responses carry
    `X-RateLimit-Limit` and `X-RateLimit-Remaining`; exceeding the limit
    returns 429 with a `Retry-After` header.
  contact:
    name: Sigilbase
    url: https://app.sigilbase.io/docs
  license:
    name: Proprietary
    url: https://app.sigilbase.io/docs
  x-rate-limit:
    limit: 60
    window: 1 minute
    scope: API key

servers:
  - url: /api/v1
    description: Resolved against the host serving this document.

security:
  - apiKey: []

tags:
  - name: Events
    description: Appending and reading events.
  - name: Proofs
    description: Inclusion proofs for sealed events.
  - name: Checkpoints
    description: Signed Merkle checkpoints.
  - name: Keys
    description: Public Ed25519 signing keys, served without authentication.

paths:
  /keys:
    get:
      operationId: listSigningKeys
      tags: [Keys]
      summary: List public signing keys
      description: |
        Every Ed25519 public key the instance has used to sign checkpoints,
        active and retired, oldest first. Requires no authentication so that
        independent verifiers never need credentials. Not rate limited.
      security: []
      responses:
        '200':
          description: All signing keys, oldest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningKeyList'
              examples:
                keys:
                  value:
                    keys:
                      - public_key: 2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12
                        algorithm: ed25519
                        created_at: '2026-07-01T00:00:00.000000Z'
                        retired_at: null

  /streams/{slug}/events:
    post:
      operationId: appendEvent
      tags: [Events]
      summary: Append one event
      description: |
        Appends an event to the stream. The event receives the next sequence
        number (dense, starting at 1) and an entry hash chained to the
        previous event. Supplying an `idempotency_key` makes retries safe:
        if the stream already holds an event with that key, nothing is
        appended and the original confirmation is returned with status 200.
      parameters:
        - $ref: '#/components/parameters/streamSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewEvent'
            examples:
              roleGranted:
                summary: A role grant with an idempotency key
                value:
                  occurred_at: '2026-07-09T09:15:00.000000Z'
                  actor: user:42
                  action: role.granted
                  resource: user:9f31
                  payload:
                    role: admin
                    granted_by: user:42
                  idempotency_key: role-grant-8842
      responses:
        '201':
          description: The event was appended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppendConfirmation'
              examples:
                appended:
                  $ref: '#/components/examples/appendConfirmation'
        '200':
          description: Idempotent replay; the original event's confirmation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppendConfirmation'
              examples:
                replayed:
                  $ref: '#/components/examples/appendConfirmation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/WriteForbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/StreamArchived'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '423':
          $ref: '#/components/responses/StreamPaused'
        '429':
          $ref: '#/components/responses/RateLimited'
    get:
      operationId: listEvents
      tags: [Events]
      summary: List events
      description: |
        Events in sequence order. Pagination is keyset by sequence: pass the
        returned `next_cursor` back as `after`. Appends never shift earlier
        pages.
      parameters:
        - $ref: '#/components/parameters/streamSlug'
        - name: after
          in: query
          required: false
          description: Return events with a sequence greater than this.
          schema:
            type: integer
            minimum: 0
        - name: limit
          in: query
          required: false
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: A page of events plus the cursor for the next page.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'

  /streams/{slug}/events:batch:
    post:
      operationId: appendEventBatch
      tags: [Events]
      summary: Append up to 100 events atomically
      description: |
        Appends the events in array order inside one transaction: either every
        event is appended or none are. Each item follows the same rules as a
        single append, including per-item idempotency keys. Returns 201 if any
        item was newly appended, 200 if every item was an idempotent replay.
      parameters:
        - $ref: '#/components/parameters/streamSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewEventBatch'
            examples:
              suspensions:
                value:
                  events:
                    - occurred_at: '2026-07-09T09:16:00Z'
                      actor: user:42
                      action: user.suspended
                      resource: user:77
                      payload:
                        reason: fraud-review
                    - occurred_at: '2026-07-09T09:16:01Z'
                      actor: user:42
                      action: user.suspended
                      resource: user:78
                      payload:
                        reason: fraud-review
      responses:
        '201':
          description: At least one event was newly appended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchConfirmation'
        '200':
          description: Every item was an idempotent replay.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchConfirmation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/WriteForbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/StreamArchived'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '423':
          $ref: '#/components/responses/StreamPaused'
        '429':
          $ref: '#/components/responses/RateLimited'

  /streams/{slug}/events/{sequence}:
    get:
      operationId: getEvent
      tags: [Events]
      summary: Get one event by sequence
      parameters:
        - $ref: '#/components/parameters/streamSlug'
        - $ref: '#/components/parameters/eventSequence'
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /streams/{slug}/events/{sequence}/proof:
    get:
      operationId: getInclusionProof
      tags: [Proofs]
      summary: Get an event's inclusion proof
      description: |
        The event, its RFC 6962 audit path, and the signed checkpoint that
        seals it. Available once the event has been sealed; before that the
        endpoint returns 409. Sealing runs every five minutes.
      parameters:
        - $ref: '#/components/parameters/streamSlug'
        - $ref: '#/components/parameters/eventSequence'
      responses:
        '200':
          description: The proof material.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InclusionProof'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The event has not been sealed into a checkpoint yet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                unsealed:
                  value:
                    message: 'This event has not been sealed into a checkpoint yet; try again after the next sealing run.'
        '429':
          $ref: '#/components/responses/RateLimited'

  /streams/{slug}/checkpoints:
    get:
      operationId: listCheckpoints
      tags: [Checkpoints]
      summary: List a stream's checkpoints
      description: |
        A page of the stream's checkpoints, ordered by `sequence_from`. Page
        with `limit` and by passing the returned `next_cursor` back as `after`.
      parameters:
        - $ref: '#/components/parameters/streamSlug'
        - name: after
          in: query
          required: false
          description: Return checkpoints with a sequence_from greater than this.
          schema:
            type: integer
            minimum: 0
        - name: limit
          in: query
          required: false
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: A page of checkpoints plus the cursor for the next page.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckpointList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /streams/{slug}/consistency:
    get:
      operationId: getConsistencyProof
      tags: [Proofs]
      summary: Get a consistency proof between two checkpoint tree states
      description: |
        An RFC 6962 consistency proof over the stream's cumulative tree of
        entry hashes, between the tree states at two checkpoint boundaries.
        A verifier holding the older state's root can confirm the newer
        state extends it without re-downloading history. `from` and `to`
        are checkpoint `sequence_to` values (list them via the checkpoints
        endpoint); tree sizes equal those sequences. Part of external
        anchoring — Business and Enterprise plans; other plans receive 403.
        The same proof can always be computed offline from full evidence
        bundles, which are available on every plan.
      parameters:
        - $ref: '#/components/parameters/streamSlug'
        - name: from
          in: query
          required: true
          description: The older checkpoint's sequence_to (the old tree size).
          schema:
            type: integer
            minimum: 1
          example: 4
        - name: to
          in: query
          required: true
          description: The newer checkpoint's sequence_to (the new tree size).
          schema:
            type: integer
            minimum: 2
          example: 9
      responses:
        '200':
          description: The consistency proof material.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConsistencyProof'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Validation failed (missing or non-increasing from/to).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: 'sgb_{key_id}.{secret}'
      description: |
        API key as a bearer token, e.g. `Authorization: Bearer sgb_example.xxxx`.
        Keys are created in the Sigilbase UI and may be scoped to specific
        streams. The secret is stored only as a hash and shown once.

  parameters:
    streamSlug:
      name: slug
      in: path
      required: true
      description: The stream's slug.
      schema:
        type: string
        pattern: '^[a-z0-9-]+$'
      example: admin-actions
    eventSequence:
      name: sequence
      in: path
      required: true
      description: The event's sequence number within the stream.
      schema:
        type: integer
        minimum: 1
      example: 3

  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
    RateLimitLimit:
      description: The per-minute request allowance for this API key.
      schema:
        type: integer
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema:
        type: integer

  responses:
    Unauthorized:
      description: Missing, malformed, revoked, or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missing:
              value:
                message: Missing bearer token.
            malformed:
              value:
                message: Malformed API key.
            invalid:
              value:
                message: Invalid or revoked API key.
    Forbidden:
      description: The API key is valid but not scoped to this stream.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notScoped:
              value:
                message: 'This API key is not scoped to stream [admin-actions].'
    WriteForbidden:
      description: |
        The API key is valid but not scoped to this stream
        (`{"message": "This API key is not scoped to stream [admin-actions]."}`),
        the write targets a reserved internal `sigilbase-*` stream
        (`{"error": "stream_reserved"}`), the tenant has been suspended
        for abuse (`{"error": "tenant_suspended"}`), or the event was
        flagged `pii: true` on a plan without the PII vault
        (`{"error": "pii_vault_not_included"}`). Reserved streams audit
        the admin panel itself and only accept writes from inside the
        application; reads remain available. Suspension is the only state
        that refuses ingestion — no billing state does — and reads and
        evidence exports keep working while it lasts.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - $ref: '#/components/schemas/StreamStateError'
          examples:
            notScoped:
              value:
                message: 'This API key is not scoped to stream [admin-actions].'
            streamReserved:
              value:
                error: stream_reserved
    StreamPaused:
      description: |
        The stream is paused and not accepting events. Reads, proofs,
        exports, and verification continue; resume is instant. The body is
        machine-readable: `{"error": "stream_paused"}`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StreamStateError'
          examples:
            paused:
              value:
                error: stream_paused
    StreamArchived:
      description: |
        The stream is archived: permanently read-only, a terminal state.
        The body is machine-readable: `{"error": "stream_archived"}`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StreamStateError'
          examples:
            archived:
              value:
                error: stream_archived
    NotFound:
      description: |
        The stream slug does not exist within the key's tenancy, or no event
        has the requested sequence. Another tenant's stream is a 404, not a
        403; the API does not confirm foreign slugs exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              value:
                message: Record not found.
    ValidationFailed:
      description: |
        Validation failed. `errors` is keyed by field; batch item errors are
        keyed `events.{index}.{field}`. Notable messages, verbatim:
        `The request body must be a JSON object.` (key `body`);
        `The events field must be an array of 1 to 100 event objects.`;
        `Non-integer numbers cannot be canonicalised; payloads must use integers only.`;
        `Canonical payload is 34063 bytes; the limit is 32768 bytes.`.
        Paused and archived streams are not validation failures: they return
        `423` and `410` with machine-readable bodies.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationError'
          examples:
            missingActor:
              value:
                message: The actor field is required and must be at most 255 characters.
                errors:
                  actor:
                    - The actor field is required and must be at most 255 characters.
            floatPayload:
              value:
                message: Non-integer numbers cannot be canonicalised; payloads must use integers only.
                errors:
                  payload:
                    - Non-integer numbers cannot be canonicalised; payloads must use integers only.
    RateLimited:
      description: More than 60 requests in a minute on this API key.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            throttled:
              value:
                message: Too Many Attempts.

  examples:
    appendConfirmation:
      value:
        id: 0197c9a4-1b2f-7c3d-9e4a-5f6b7c8d9e0f
        sequence: 1
        entry_hash: 9b89974c651b484589b61d710f1bc0284e7788bf3ace3e833a46f5729d694c01
        received_at: '2026-07-09T09:15:03.412876Z'

  schemas:
    Sha256Hex:
      type: string
      pattern: '^[0-9a-f]{64}$'
      description: SHA-256 as 64 lowercase hex characters.

    Rfc3339Micro:
      type: string
      format: date-time
      description: RFC 3339 UTC timestamp with microseconds, e.g. 2026-07-09T09:15:03.412876Z.

    Payload:
      type: object
      description: |
        Caller-defined JSON object, at most 32,768 bytes after RFC 8785
        canonicalisation. Numbers must be integers with absolute value at
        most 2^53 - 1; floats are rejected. Strings must be valid UTF-8.
      additionalProperties: true

    NewEvent:
      type: object
      required: [occurred_at, actor, action, payload]
      properties:
        occurred_at:
          allOf:
            - $ref: '#/components/schemas/Rfc3339Micro'
          description: When the event happened, by the caller's clock. RFC 3339, at most 24 hours in the future.
        actor:
          type: string
          minLength: 1
          maxLength: 255
          description: Who did it, in the caller's own convention.
        action:
          type: string
          minLength: 1
          maxLength: 255
          description: What happened.
        resource:
          type: ['string', 'null']
          maxLength: 255
          description: What it happened to. Optional.
        payload:
          $ref: '#/components/schemas/Payload'
        pii:
          type: boolean
          default: false
          description: >-
            Store this payload in the PII vault (Business/Enterprise):
            hashing runs over the plaintext exactly as normal, then the
            payload is envelope-encrypted at rest under a per-event data
            key. Reads decrypt for your own users; Sigilbase operators
            can read nothing, before or after.
        subject:
          type: ['string', 'null']
          minLength: 1
          maxLength: 255
          description: >-
            Optional data-subject reference for a pii-flagged event
            (e.g. "patient:9f31"). Groups vault data keys so an erasure
            request is one operation — destroying the subject's keys.
            Requires pii: true.
        idempotency_key:
          type: ['string', 'null']
          minLength: 1
          maxLength: 255
          description: Unique per stream. A replay with the same key appends nothing and returns the original confirmation.

    NewEventBatch:
      type: object
      required: [events]
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/NewEvent'

    AppendConfirmation:
      type: object
      required: [id, sequence, entry_hash, received_at]
      properties:
        id:
          type: string
          format: uuid
        sequence:
          type: integer
          minimum: 1
        entry_hash:
          $ref: '#/components/schemas/Sha256Hex'
        received_at:
          $ref: '#/components/schemas/Rfc3339Micro'

    BatchConfirmation:
      type: object
      required: [events]
      properties:
        events:
          type: array
          items:
            $ref: '#/components/schemas/AppendConfirmation'

    Event:
      type: object
      required:
        - id
        - sequence
        - occurred_at
        - received_at
        - actor
        - action
        - resource
        - payload
        - payload_hash
        - prev_hash
        - entry_hash
        - checkpoint_id
        - payload_redacted_at
      properties:
        id:
          type: string
          format: uuid
        sequence:
          type: integer
          minimum: 1
        occurred_at:
          $ref: '#/components/schemas/Rfc3339Micro'
        received_at:
          $ref: '#/components/schemas/Rfc3339Micro'
        actor:
          type: string
        action:
          type: string
        resource:
          type: ['string', 'null']
        payload:
          oneOf:
            - $ref: '#/components/schemas/Payload'
            - type: 'null'
          description: >-
            The event payload, or null when the payload has been redacted
            (see payload_redacted_at). Hashes are preserved through
            redaction.
        payload_hash:
          $ref: '#/components/schemas/Sha256Hex'
        prev_hash:
          allOf:
            - $ref: '#/components/schemas/Sha256Hex'
          description: The previous event's entry hash; 64 zeros for sequence 1.
        entry_hash:
          $ref: '#/components/schemas/Sha256Hex'
        checkpoint_id:
          type: ['string', 'null']
          format: uuid
          description: Set once the event is sealed; null until then.
        payload_redacted_at:
          oneOf:
            - $ref: '#/components/schemas/Rfc3339Micro'
            - type: 'null'
          description: >-
            When the tenant destroyed this event's stored payload
            (hash-preserving redaction); null for events whose payload is
            present. A redaction always appends a payload.redacted event
            to the ledger, so absence is declared, never implied.

    EventEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/Event'

    EventList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Event'
        next_cursor:
          type: ['integer', 'null']
          description: Pass back as `after` to fetch the next page; null when the listing is exhausted.

    Checkpoint:
      type: object
      required:
        - id
        - sequence_from
        - sequence_to
        - merkle_root
        - prev_checkpoint_hash
        - checkpoint_hash
        - signature
        - public_key
        - created_at
      properties:
        id:
          type: string
          format: uuid
        sequence_from:
          type: integer
          minimum: 1
        sequence_to:
          type: integer
          minimum: 1
        merkle_root:
          allOf:
            - $ref: '#/components/schemas/Sha256Hex'
          description: RFC 6962 Merkle root over the entry hashes of events sequence_from..sequence_to.
        prev_checkpoint_hash:
          allOf:
            - $ref: '#/components/schemas/Sha256Hex'
          description: The previous checkpoint's hash; 64 zeros for the first.
        checkpoint_hash:
          $ref: '#/components/schemas/Sha256Hex'
        signature:
          type: string
          pattern: '^[0-9a-f]{128}$'
          description: Ed25519 signature over the raw 32 bytes of checkpoint_hash.
        public_key:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: The Ed25519 public key that signed this checkpoint.
        created_at:
          $ref: '#/components/schemas/Rfc3339Micro'

    CheckpointList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Checkpoint'
        next_cursor:
          type: ['integer', 'null']
          description: Pass back as `after` to fetch the next page; null when the listing is exhausted.

    AuditPathStep:
      type: object
      required: [hash, side]
      properties:
        hash:
          $ref: '#/components/schemas/Sha256Hex'
        side:
          type: string
          enum: [left, right]
          description: Which side the sibling hash sits on when recombining.

    InclusionProof:
      type: object
      required: [stream, event, proof, checkpoint]
      properties:
        stream:
          type: object
          required: [id, slug]
          description: >-
            The stream identity. Entry hashes and checkpoint hashes commit to
            the stream UUID, so a local verifier needs it to recompute them.
          properties:
            id:
              type: string
              format: uuid
            slug:
              type: string
        event:
          $ref: '#/components/schemas/Event'
        proof:
          type: object
          required: [leaf_index, audit_path, merkle_root]
          properties:
            leaf_index:
              type: integer
              minimum: 0
              description: Zero-based position within the checkpoint range (sequence - sequence_from).
            audit_path:
              type: array
              items:
                $ref: '#/components/schemas/AuditPathStep'
              description: RFC 6962 audit path, ordered leaf to root. Empty for a single-event checkpoint.
            merkle_root:
              $ref: '#/components/schemas/Sha256Hex'
        checkpoint:
          $ref: '#/components/schemas/Checkpoint'

    ConsistencyTreeState:
      type: object
      required: [tree_size, root, checkpoint_hash]
      properties:
        tree_size:
          type: integer
          minimum: 1
          description: The number of entries the cumulative tree covers (the checkpoint's sequence_to).
        root:
          $ref: '#/components/schemas/Sha256Hex'
        checkpoint_hash:
          $ref: '#/components/schemas/Sha256Hex'

    ConsistencyProof:
      type: object
      required: [stream, from, to, proof]
      properties:
        stream:
          type: string
          description: The stream slug.
        from:
          $ref: '#/components/schemas/ConsistencyTreeState'
        to:
          $ref: '#/components/schemas/ConsistencyTreeState'
        proof:
          type: array
          items:
            $ref: '#/components/schemas/Sha256Hex'
          description: |
            RFC 6962 consistency proof nodes, in verification order. Empty
            when the tree sizes are equal.

    SigningKey:
      type: object
      required: [public_key, algorithm, created_at, retired_at]
      properties:
        public_key:
          type: string
          pattern: '^[0-9a-f]{64}$'
        algorithm:
          type: string
          const: ed25519
        created_at:
          $ref: '#/components/schemas/Rfc3339Micro'
        retired_at:
          oneOf:
            - $ref: '#/components/schemas/Rfc3339Micro'
            - type: 'null'
          description: Null while the key is active.

    SigningKeyList:
      type: object
      required: [keys]
      properties:
        keys:
          type: array
          items:
            $ref: '#/components/schemas/SigningKey'

    Error:
      type: object
      required: [message]
      properties:
        message:
          type: string

    StreamStateError:
      description: Machine-readable stream-state rejection (paused, archived, reserved, tenant suspended).
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [stream_paused, stream_archived, stream_reserved, tenant_suspended]

    ValidationError:
      type: object
      required: [message, errors]
      properties:
        message:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
