openapi: 3.1.0
# Financewolf E-Invoicing public contract, V1 (FW-013).
#
# This document is the contract FW-020 implements. It is deliberately narrow:
# the synchronous validation journey and read access to the immutable ruleset
# registry. Generation, rendering, reports, jobs, batches, documents and Peppol
# transport are owned by later slices, which extend this file rather than
# being guessed at here.
#
# Conventions: every response body is either a versioned result document or an
# RFC 9457 Problem. Validation findings are never Problems - a document that
# fails the standard is a successfully processed validation. Arrays are always
# present and never null. Hashes are lowercase hex. Times are RFC 3339 UTC.
#
# Serving obligation (FW-020): this document is served at GET /openapi.yaml
# under the product prefix, and every example it references by externalValue
# is served at exactly that URL (GET /examples/{name} under the same prefix),
# from the same embedded filesystem (ContractFS) so the two cannot diverge.
info:
  title: Financewolf E-Invoicing API
  version: "1.0"
  summary: Validate e-invoices against immutable, reproducible Peppol rulesets.
  description: |
    Financewolf validates UBL e-invoices against the exact EN 16931 and Peppol
    BIS Billing rule releases published by the standards bodies, and returns a
    result that names the immutable ruleset it used so the outcome can be
    reproduced later.

    **Authentication.** The V1 boundary accepts Ironfang platform API keys
    only (`Authorization: Bearer <key>`), minted in the Ironfang portal with
    Financewolf scopes. There is no OAuth or user-token access to this API
    yet; nothing in this contract should be read as advertising one.

    **Anonymous use.** `POST /v1/einvoices/validate` and both ruleset reads
    accept requests with no credentials. Anonymous validation is validation
    only: no operation or document is stored, no report or evidence link is
    produced, no usage or entitlement information is returned, and tighter
    per-address rate limits apply. The result shape is otherwise identical.

    **Saved results.** Authenticated response bytes and findings are kept for
    30 days and may be deleted earlier through the result API. Raw XML is
    never stored. After expiry/deletion, replay under the same Idempotency-Key
    answers `410 result_gone` without running or charging again. Operation
    identity hashes and usage metadata remain until organisation erasure.
    Existing results receive 30 days from the retention migration.

    **Validation is not transport.** Any `scheduled`, `sendable` or
    `historical` release may be validated against by exact id: historical
    for audit and reproduction, scheduled for migration testing. Only the
    keyword `latest` (and profile detection) is restricted to the currently
    `sendable` release. Sending a document over Peppol - a later slice -
    is what requires a sendable release; validation never does.

    **Indeterminate outcomes.** A timeout, validator crash or internal
    dependency failure is *indeterminate*: it is never reported as `invalid`
    and never billed as a completed validation. Indeterminate outcomes are
    delivered as Problems - `504 validation_timeout`,
    `503 validator_unavailable` or `500 internal_error` on the validate
    operation - each carrying `outcome: indeterminate` and
    `usage_charged: false`. A `200` validation response is a verdict:
    `status: completed` with `outcome` `valid` or `invalid`, and no layer in
    `error`; the response schema (`ValidationVerdict`) admits nothing else.
  contact:
    name: Ironfang
    url: https://ironfang.uk/financewolf
  license:
    name: Proprietary
    identifier: LicenseRef-Ironfang-Proprietary

servers:
  - url: https://api.ironfang.uk/financewolf
    description: Production. Paths below are relative to this product prefix.

tags:
  - name: validation
    description: Synchronous e-invoice validation.
  - name: rulesets
    description: The immutable ruleset registry validation results are pinned to.

security:
  - {}
  - platformKey: []

paths:
  /v1/einvoices/validate:
    post:
      tags: [validation]
      operationId: validateEInvoice
      summary: Validate one e-invoice XML document
      description: |
        Validates a single UBL Invoice or CreditNote document synchronously and
        returns a `ValidationVerdict`.

        **Body.** Send the raw XML as the request body with
        `Content-Type: application/xml` or `text/xml`, choosing the ruleset
        with the query parameters. Or send `multipart/form-data` with a
        `document` part holding the XML and an optional `options` part
        holding a JSON `ValidationOptions` object; query parameters are not
        read for multipart requests. The multipart form is the one to use
        from browsers and low-code tools.

        **Strictness.** For raw XML bodies, a query parameter that is
        repeated or not one of `ruleset`, `profile` and `document_type` is
        `400 malformed_request`, so a misspelt selector cannot silently fall
        back to a default. For multipart bodies, exactly one `document`
        part (an XML media type) and at most one `options` part
        (`application/json`) are accepted; a repeated or unknown part is
        `400 malformed_request`. A `Content-Encoding` other than identity is
        `415 unsupported_content_type`.

        **Correlation.** Every response carries `X-Ironfang-Request-ID`,
        Ironfang's id for the request, which every Problem repeats as
        `request_id`. Send your own `X-Request-ID` and it is echoed back
        unchanged.

        **Limits.** The XML is at most 5 MiB (5,242,880 bytes), 64 levels
        deep and 250,000 elements. Archives and compressed bodies
        (`Content-Encoding` other than identity, ZIP, gzip) are refused with
        `415 unsupported_content_type`; a larger body with
        `413 payload_too_large`. The validation deadline is 10 seconds; a
        validation that exceeds it is indeterminate (`504`).

        **Ruleset selection.** `ruleset` is either the keyword `latest` or an
        exact immutable ruleset id (`fwrs_...`).

        - `latest` resolves to the currently `sendable` release for the
          document's profile family at the moment of the request. With a
          `profile`, the result reports `selection_method: latest`; without
          one, the family is detected from the document's `CustomizationID`
          and `ProfileID` and the result reports `selection_method:
          detected`. Detection is for validation only; it is never a
          statement about what the document should be. A document whose
          profile cannot be detected - including XML that does not parse -
          is `422 profile_undetected`; name a `profile` or an exact ruleset
          to obtain a verdict on it.
        - An exact id validates against that release whatever its state
          among `scheduled` (migration testing), `sendable` (current) and
          `historical` (audit and reproduction). The result reports
          `selection_method: exact` and the release's `state`. An id that is
          unknown - or names an internal candidate, which is
          indistinguishable from unknown - is `422 unknown_ruleset`; a
          withdrawn release is `410 ruleset_disabled`.

        The result always names the resolved immutable id in `ruleset.id`.

        **Outcome versus status.** The result separates how far execution
        got (`status`) from what the standard concluded (`outcome`). A
        document that fails the standard at any layer - including malformed
        XML at the `xml` layer - is a successfully processed validation:
        HTTP `200` with `outcome: invalid`, and every deeper layer that could
        not run is reported as `skipped`, never omitted. Only the request
        itself being wrong, or Financewolf being unable to decide, produces a
        Problem.

        **Idempotency.** Authenticated requests may send an
        `Idempotency-Key` (8-128 characters of `A-Z a-z 0-9 _ -`). The key
        is scoped to the organisation and the validation endpoint. Replaying
        the same request with the same key returns the original operation
        and its result - the same `operation_id`, no second execution, no
        second charge - even if `latest` has since come to mean a newer
        release. A request is identified by its content: reusing a key with
        different document bytes (a different input SHA-256) or a different
        ruleset, profile or document type is `409 idempotency_conflict`.
        Requests without a key always create a new operation. Anonymous
        requests do not use the header.

        **Findings.** At most 1,000 findings are returned inline, in
        deterministic order: layer order, then severity (`fatal` before
        `warning`), then `rule_id`, then `location`, then `message`.
        `findings_summary` always states how many were returned, how many
        exist, the cap, and whether truncation happened.
      security:
        - {}
        - platformKey: [financewolf:einvoices:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: ruleset
          in: query
          description: "`latest` (default) or an exact ruleset id. Read for raw XML bodies only."
          required: false
          schema:
            $ref: '#/components/schemas/RulesetSelector'
        - name: profile
          in: query
          description: "Profile family to resolve `latest` within. Omit to detect it from the document. Read for raw XML bodies only."
          required: false
          schema:
            $ref: '#/components/schemas/ProfileFamily'
        - name: document_type
          in: query
          description: "Expected document type, or `auto` (default) to detect it from the root element. A mismatch is `422 unsupported_document_type`. Read for raw XML bodies only."
          required: false
          schema:
            $ref: '#/components/schemas/DocumentTypeSelector'
      requestBody:
        required: true
        content:
          application/xml:
            schema:
              $ref: '#/components/schemas/XMLDocument'
          text/xml:
            schema:
              $ref: '#/components/schemas/XMLDocument'
          multipart/form-data:
            schema:
              type: object
              additionalProperties: false
              required: [document]
              properties:
                document:
                  $ref: '#/components/schemas/XMLDocument'
                options:
                  $ref: '#/components/schemas/ValidationOptions'
            encoding:
              document:
                contentType: application/xml, text/xml
              options:
                contentType: application/json
      responses:
        '200':
          description: >-
            A verdict. `status` is `completed`, `outcome` is `valid` or
            `invalid`, and no layer is in `error`. A standards failure at any
            layer is this response, not a Problem.
          headers:
            X-Ironfang-Request-ID:
              $ref: '#/components/headers/XIronfangRequestID'
            X-Request-ID:
              $ref: '#/components/headers/XRequestIDEcho'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationVerdict'
              examples:
                valid:
                  summary: A conformant invoice, authenticated, latest within a named profile
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-validation-result-valid.json
                invalid-findings:
                  summary: An invoice that fails Peppol rules, anonymous, profile detected
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-validation-result-invalid.json
                malformed-xml:
                  summary: Malformed XML against an exact ruleset - deeper layers skipped
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-validation-result-malformed-xml.json
                exact-historical:
                  summary: Reproduction against an exact historical release
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-validation-result-exact-historical.json
                exact-scheduled:
                  summary: Migration testing against an exact scheduled release
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-validation-result-exact-scheduled.json
        '400':
          $ref: '#/components/responses/MalformedRequest'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '410':
          $ref: '#/components/responses/ValidationGone'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedContentType'
        '422':
          $ref: '#/components/responses/UnsupportedSelection'
        '429':
          $ref: '#/components/responses/ValidationRateLimited'
        '500':
          $ref: '#/components/responses/ValidationInternalError'
        '503':
          $ref: '#/components/responses/ValidatorUnavailable'
        '504':
          $ref: '#/components/responses/ValidationTimeout'

  /v1/einvoices/rulesets:
    get:
      tags: [rulesets]
      operationId: listEInvoiceRulesets
      summary: List rulesets available for selection and reproduction
      description: |
        Lists the immutable rulesets a caller may validate against:
        `scheduled`, `sendable` and `historical` releases. Candidate releases
        are internal and never listed; `disabled` releases are withdrawn and
        never appear in a successful body - their ids answer `410` on the
        exact read.

        Exactly one `sendable` release per profile family and document type
        carries `latest: true`: that is what `ruleset: latest` resolves to for
        a request made now. `scheduled` releases show what `latest` will
        become and when, and may be validated against by exact id for
        migration testing; `historical` releases stay retrievable and
        validatable by exact id so an older result can be reproduced.
      security:
        - {}
        - platformKey: [financewolf:einvoices:rulesets:read]
      parameters:
        - name: document_type
          in: query
          required: false
          description: "Restrict to one document type. Omit for both. A repeated or unknown query parameter is `400 malformed_request`."
          schema:
            $ref: '#/components/schemas/DocumentType'
        - name: profile
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/ProfileFamily'
        - name: state
          in: query
          required: false
          description: Restrict to one lifecycle state.
          schema:
            $ref: '#/components/schemas/RulesetState'
      responses:
        '200':
          description: The matching rulesets, newest effective date first.
          headers:
            X-Ironfang-Request-ID:
              $ref: '#/components/headers/XIronfangRequestID'
            X-Request-ID:
              $ref: '#/components/headers/XRequestIDEcho'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RulesetList'
              examples:
                list:
                  summary: Upcoming, current and past releases for invoices
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-ruleset-list.json
        '400':
          $ref: '#/components/responses/MalformedRequest'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '429':
          $ref: '#/components/responses/ReadRateLimited'
        '500':
          $ref: '#/components/responses/ReadInternalError'

  /v1/einvoices/rulesets/{id}:
    get:
      tags: [rulesets]
      operationId: getEInvoiceRuleset
      summary: Fetch one ruleset by its immutable id
      description: |
        Returns the customer-safe identity of one ruleset: enough to select
        it, to know its state, and to reproduce a validation that named it.
        `scheduled`, `sendable` and `historical` releases are returned in
        full. A `disabled` release answers `410 ruleset_disabled`: its id is
        acknowledged, its content is withdrawn, and it never appears in a
        successful body. `latest` is a selection keyword, not an id:
        `GET .../rulesets/latest` is `404`. Candidate releases are internal
        and indistinguishable from unknown: `404`.
      security:
        - {}
        - platformKey: [financewolf:einvoices:rulesets:read]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/RulesetID'
      responses:
        '200':
          description: The ruleset.
          headers:
            X-Ironfang-Request-ID:
              $ref: '#/components/headers/XIronfangRequestID'
            X-Request-ID:
              $ref: '#/components/headers/XRequestIDEcho'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Ruleset'
              examples:
                historical:
                  summary: An exact historical release, still reproducible
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-ruleset-historical.json
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/RulesetDisabled'
        '429':
          $ref: '#/components/responses/ReadRateLimited'
        '500':
          $ref: '#/components/responses/ReadInternalError'

  /v1/einvoices/results:
    get:
      operationId: listValidationResults
      summary: List saved validation results
      description: Returns up to 20 results, newest first. Use next_cursor as before for another page. Available
        is false once expired or deleted. Requires financewolf:einvoices:read.
      tags:
      - Validation
      security:
      - platformKey: []
      parameters:
      - name: before
        in: query
        schema:
          $ref: '#/components/schemas/OpaqueID'
        description: Last operation ID from the preceding page.
      responses:
        '200':
          description: Tenant-owned saved results. Cache-Control is no-store.
          headers: &id001
            X-Ironfang-Request-ID:
              $ref: '#/components/headers/XIronfangRequestID'
            X-Request-ID:
              $ref: '#/components/headers/XRequestIDEcho'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RetainedResultList'
              examples:
                empty:
                  externalValue: https://api.ironfang.uk/financewolf/examples/v1-retained-results-empty.json
        '400': &id002
          $ref: '#/components/responses/MalformedRequest'
        '401': &id003
          $ref: '#/components/responses/Unauthenticated'
        '403': &id004
          $ref: '#/components/responses/InsufficientScope'
        '404': &id005
          $ref: '#/components/responses/NotFound'
        '429': &id006
          $ref: '#/components/responses/ReadRateLimited'
        '500': &id007
          $ref: '#/components/responses/ReadInternalError'
  /v1/einvoices/results/{id}:
    get:
      operationId: getValidationResult
      summary: Read a saved validation result
      description: Returns the exact original JSON while retained. Unknown or foreign IDs answer 404; expired/deleted
        results answer 410. Requires financewolf:einvoices:read.
      tags:
      - Validation
      security:
      - platformKey: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          $ref: '#/components/schemas/OpaqueID'
      responses:
        '200':
          description: Tenant-owned saved results. Cache-Control is no-store.
          headers: *id001
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationVerdict'
        '400': *id002
        '401': *id003
        '403': *id004
        '404': *id005
        '429': *id006
        '500': *id007
        '410':
          $ref: '#/components/responses/ResultGone'
    delete:
      operationId: deleteValidationResult
      summary: Delete saved result bytes and findings
      description: Requires financewolf:einvoices:write. Erases response and findings atomically. Repeated
        deletion returns 204. Operation/usage metadata and idempotency hashes remain; replay will never
        repeat the validation. Unknown or foreign IDs answer 404.
      tags:
      - Validation
      security:
      - platformKey: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          $ref: '#/components/schemas/OpaqueID'
      responses:
        '204':
          description: Result bytes and findings are deleted; operation and usage metadata remain.
          headers: *id001
        '400': *id002
        '401': *id003
        '403': *id004
        '404': *id005
        '429': *id006
        '500': *id007

components:
  securitySchemes:
    platformKey:
      type: http
      scheme: bearer
      description: >-
        An Ironfang platform API key minted in the portal. Scopes listed on
        an operation are the ones the key must carry. Keys are the only
        credential this boundary accepts in V1.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Optional on authenticated requests. The same key with the same
        request replays the original operation; the same key with a
        different request (different document bytes, ruleset, profile or
        document type) is `409 idempotency_conflict`. Scoped to the
        organisation and the validation endpoint. Anonymous requests never
        use it.
      schema:
        type: string
        minLength: 8
        maxLength: 128
        pattern: '^[A-Za-z0-9_-]+$'

  headers:
    XIronfangRequestID:
      description: >-
        Ironfang's internal request id, minted at the edge for every request
        and carried in every Problem body as `request_id`. Quote it in support
        requests.
      schema:
        $ref: '#/components/schemas/RequestID'
    XRequestIDEcho:
      description: >-
        Your own correlation id, echoed back when the request carried a
        well-formed `X-Request-ID`: visible ASCII only (bytes 0x21 to 0x7E, so
        no spaces or control characters), 1 to 128 bytes. Anything else is
        ignored, not echoed. Never used as Ironfang's identifier for the
        request.
      required: false
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: '^[\x21-\x7E]{1,128}$'
    RetryAfter:
      description: Seconds to wait before retrying. Mirrors `retry_after_seconds` in the body.
      schema:
        type: integer
        minimum: 1

  # Every error response pins the exact Problem codes and HTTP status it can
  # carry: a 401 body cannot validate as a timeout merely because both are
  # Problems. The base Problem schema fixes each code's status and type.
  responses:
    ValidationGone:
      description: The selected release is withdrawn, or the original saved result expired or was deleted.
        Result replay never executes again.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
            - $ref: '#/components/schemas/Problem'
            - properties:
                code:
                  enum:
                  - ruleset_disabled
                  - result_gone
                status:
                  const: 410
    ResultGone:
      description: This validation result expired or was deleted. Operation and usage metadata remain.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
            - $ref: '#/components/schemas/Problem'
            - properties:
                code:
                  enum:
                  - result_gone
                status:
                  const: 410
    MalformedRequest:
      description: The request itself is wrong - not the document being validated.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [malformed_request] }
                  status: { const: 400 }
          examples:
            field-errors:
              summary: Bad option values
              externalValue: https://api.ironfang.uk/financewolf/examples/v1-problem-field-errors.json
    Unauthenticated:
      description: A credential was presented and could not be verified. Anonymous access, where permitted, sends no credential at all.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [unauthenticated] }
                  status: { const: 401 }
    InsufficientScope:
      description: The key is valid but does not carry the scope this operation needs.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [insufficient_scope] }
                  status: { const: 403 }
    IdempotencyConflict:
      description: >-
        The `Idempotency-Key` was already used by this organisation, on the
        validation endpoint, for a request with different content. The
        original operation is left untouched; send a new key for a new
        request.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [idempotency_conflict] }
                  status: { const: 409 }
    PayloadTooLarge:
      description: The body exceeds 5 MiB.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [payload_too_large] }
                  status: { const: 413 }
    UnsupportedContentType:
      description: The body is not XML (an archive, a compressed body, or another media type).
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [unsupported_content_type] }
                  status: { const: 415 }
    UnsupportedSelection:
      description: >-
        The requested ruleset, profile or document type cannot be used:
        `unknown_ruleset` (unknown, or an internal candidate),
        `unsupported_profile`, `profile_undetected` or
        `unsupported_document_type`. The document was not validated.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [unknown_ruleset, unsupported_profile, profile_undetected, unsupported_document_type] }
                  status: { const: 422 }
    ValidationRateLimited:
      description: Per-address or per-key rate limit (`rate_limited`), or an exhausted plan quota (`quota_exhausted`). Both may ask for a retry.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [rate_limited, quota_exhausted] }
                  status: { const: 429 }
    ReadRateLimited:
      description: Per-address or per-key rate limit. Ruleset reads are not metered, so no quota problem is possible here.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [rate_limited] }
                  status: { const: 429 }
    NotFound:
      description: No such ruleset visible to this caller (unknown, an internal candidate, or the keyword `latest`).
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [not_found] }
                  status: { const: 404 }
    RulesetDisabled:
      description: The ruleset id is known but the release has been withdrawn from every use, validation included.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [ruleset_disabled] }
                  status: { const: 410 }
    ValidationInternalError:
      description: >-
        Financewolf failed while validating, in a way that is not the
        validator's own. Indeterminate: the body says `outcome:
        indeterminate` and `usage_charged: false`.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - required: [outcome, usage_charged]
                properties:
                  code: { enum: [internal_error] }
                  status: { const: 500 }
          examples:
            internal:
              summary: An internal failure during validation
              externalValue: https://api.ironfang.uk/financewolf/examples/v1-problem-validation-internal.json
    ReadInternalError:
      description: >-
        Financewolf failed while reading the registry. Not a validation
        verdict: the body carries no outcome or usage fields.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [internal_error] }
                  status: { const: 500 }
                not:
                  anyOf:
                    - required: [outcome]
                    - required: [usage_charged]
                    - required: [operation_id]
    ValidatorUnavailable:
      description: >-
        The validator crashed, was unavailable, or an internal dependency
        failed. Indeterminate: `outcome: indeterminate`, `usage_charged:
        false`. Retry after the indicated delay.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [validator_unavailable] }
                  status: { const: 503 }
          examples:
            unavailable:
              summary: Validator dependency failure
              externalValue: https://api.ironfang.uk/financewolf/examples/v1-problem-indeterminate-unavailable.json
    ValidationTimeout:
      description: >-
        The 10-second validation deadline passed. Indeterminate: `outcome:
        indeterminate`, `usage_charged: false`.
      headers:
        X-Ironfang-Request-ID:
          $ref: '#/components/headers/XIronfangRequestID'
        X-Request-ID:
          $ref: '#/components/headers/XRequestIDEcho'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - properties:
                  code: { enum: [validation_timeout] }
                  status: { const: 504 }
          examples:
            timeout:
              summary: Deadline exceeded
              externalValue: https://api.ironfang.uk/financewolf/examples/v1-problem-indeterminate-timeout.json

  schemas:
    RetainedResultList:
      type: object
      additionalProperties: false
      required:
      - results
      - retention_days
      properties:
        retention_days:
          type: integer
          const: 30
        next_cursor:
          $ref: '#/components/schemas/OpaqueID'
        results:
          type: array
          maxItems: 20
          items:
            type: object
            additionalProperties: false
            required:
            - operation_id
            - outcome
            - ruleset_id
            - created_at
            - expires_at
            - available
            properties:
              operation_id:
                $ref: '#/components/schemas/OpaqueID'
              outcome:
                type: string
                enum:
                - valid
                - invalid
              ruleset_id:
                type: string
                pattern: ^fwrs_[a-z0-9_]{1,80}$
              created_at:
                type: string
                format: date-time
              expires_at:
                type: string
                format: date-time
              deleted_at:
                type: string
                format: date-time
              available:
                type: boolean
    # ------------------------------------------------------------------ scalars
    XMLDocument:
      type: string
      contentMediaType: application/xml
      description: A UBL 2.1 Invoice or CreditNote document, at most 5 MiB.
      maxLength: 5242880

    RulesetID:
      type: string
      description: An immutable ruleset identifier. Never reused, never edited.
      pattern: '^fwrs_[a-z0-9_]{1,80}$'

    RulesetSelector:
      type: string
      description: "`latest`, or an exact ruleset id in any validatable state."
      default: latest
      pattern: '^(latest|fwrs_[a-z0-9_]{1,80})$'

    OpaqueID:
      type: string
      description: An opaque Ironfang identifier.
      minLength: 1
      maxLength: 64
      pattern: '^[A-Za-z0-9_-]+$'

    RequestID:
      type: string
      pattern: '^[A-Za-z0-9_-]{8,64}$'

    SHA256:
      type: string
      description: Lowercase hex SHA-256.
      pattern: '^[0-9a-f]{64}$'

    ImageDigest:
      type: string
      description: The pinned validator engine image, as a sha256 digest.
      pattern: '^sha256:[0-9a-f]{64}$'

    Timestamp:
      type: string
      format: date-time
      description: RFC 3339, UTC.

    HTTPSURL:
      type: string
      format: uri
      description: >-
        A provenance URL under one narrow grammar: https, a lowercase DNS
        host of at least two labels, no userinfo, no IP literal, an optional
        port 1..65535, an optional path, query and fragment of unreserved and
        sub-delimiter characters or valid percent-escapes, no whitespace. The
        same expression is held by the registry review, the database and
        this document.
      pattern: '^https://[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.[a-z]([a-z0-9-]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/([A-Za-z0-9._~!$&()*+,;=:@/-]|%[0-9A-Fa-f]{2})*)?(\?([A-Za-z0-9._~!$&()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(#([A-Za-z0-9._~!$&()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?$'

    FindingMessage:
      type: string
      description: >-
        One of Ironfang's fixed finding messages, selected by the finding's
        rule id (for the validator's own FW-* rules) or by its layer. No
        message text from the validation engine or a rule set is ever
        returned: the rule id and the sanitised location carry the detail.
        The union below is exactly the closed catalogue
        (services/financewolf-validator/corpus/finding-messages.json); a
        result carrying any other message is refused before it is stored or
        written.
      enum:
        - "A Peppol BIS Billing 3.0 rule failed at this location."
        - "An EN 16931 business rule failed at this location."
        - "The document could not be read as XML within the contract limits."
        - "The document does not conform to the UBL 2.1 schema at this location."
        - "The document encoding, declaration or structure is not accepted."
        - "The document has more elements than the contract limit of 250,000."
        - "The document nests elements deeper than the contract limit of 64."
        - "The input is empty or larger than the contract limit of 5,242,880 bytes."
        - "The input was refused by the contract limits before parsing."

    DocumentType:
      type: string
      enum: [invoice, credit_note]

    DocumentTypeSelector:
      type: string
      enum: [invoice, credit_note, auto]
      default: auto

    ProfileFamily:
      type: string
      description: A supported profile family. V1 supports Peppol BIS Billing 3.
      enum: [peppol-bis-billing-3]

    RulesetState:
      type: string
      description: >-
        Public lifecycle state of a release that may be validated against.
        `sendable` is current and is what `latest` resolves to; `scheduled`
        is the next release, not yet in force, validatable for migration
        testing; `historical` has been superseded but stays validatable for
        reproduction. Withdrawn (`disabled`) releases answer `410` and never
        appear in a body; candidate releases are internal and never appear.
      enum: [scheduled, sendable, historical]

    Layer:
      type: string
      description: A validation layer, in execution order.
      enum: [input, xml, xsd, en16931, peppol]

    LayerStatus:
      type: string
      enum: [passed, failed, skipped, error]

    LayerReason:
      type: string
      description: >-
        Why a layer did not run to a verdict. `skipped_after_failure`: an
        earlier layer failed and this one cannot execute safely.
        `not_applicable`: the layer does not exist for the selected profile.
        `engine_error`: the validator failed inside this layer.
        `deadline_exceeded`: the deadline passed during this layer.
      enum: [skipped_after_failure, not_applicable, engine_error, deadline_exceeded]

    Severity:
      type: string
      description: "`fatal` findings make the document invalid; `warning` findings never do and are never promoted."
      enum: [fatal, warning]

    Outcome:
      type: string
      description: >-
        The standard's verdict. `indeterminate` means Financewolf could not
        decide; it is never converted to `invalid` and never billed.
      enum: [valid, invalid, indeterminate]

    OperationStatus:
      type: string
      description: How far execution got, independent of the verdict.
      enum: [completed, error]

    SelectionMethod:
      type: string
      description: >-
        How the ruleset was chosen: `exact` (the request named the id),
        `latest` (resolved from the requested profile), `detected` (profile
        detected from the document, then resolved as latest).
      enum: [exact, latest, detected]

    LocationKind:
      type: string
      description: "The vocabulary the validator and the findings store use: `none`, `xpath`, or `line-column`."
      enum: [none, xpath, line-column]

    LicenceStatus:
      type: string
      description: >-
        Ironfang's licence review of a rule artefact: `permissive`,
        `notice-required` (attribution travels with derived output), or
        `no-raw-redistribution` (Financewolf executes it; raw files are not
        served).
      enum: [permissive, notice-required, no-raw-redistribution]

    # ---------------------------------------------------------------- request
    ValidationOptions:
      type: object
      description: >-
        Options for the multipart form. Unknown fields are rejected so a
        misspelt option cannot silently fall back to a default.
      additionalProperties: false
      properties:
        ruleset:
          $ref: '#/components/schemas/RulesetSelector'
        profile:
          $ref: '#/components/schemas/ProfileFamily'
        document_type:
          $ref: '#/components/schemas/DocumentTypeSelector'

    # ----------------------------------------------------------------- result
    ValidationResult:
      type: object
      description: >-
        The general result model: what a validation produced, whether or
        not it reached a verdict. Retained deliberately broad so a stored
        or asynchronous operation (later slices) can represent an
        indeterminate run. The synchronous `200` response uses the
        narrower `ValidationVerdict`.
      additionalProperties: false
      required:
        - schema
        - status
        - outcome
        - input
        - ruleset
        - profile
        - layers
        - counts
        - findings
        - findings_summary
        - timing
        - engine
      properties:
        schema:
          type: string
          description: The version of this result document.
          const: financewolf/einvoice/validation-result/v1
        operation_id:
          $ref: '#/components/schemas/OpaqueID'
          description: Present for authenticated requests, which create an operation record. Absent for anonymous validation.
        document_id:
          $ref: '#/components/schemas/OpaqueID'
          description: Present when the authenticated organisation's retention policy stored the document. Absent otherwise.
        status:
          $ref: '#/components/schemas/OperationStatus'
        outcome:
          $ref: '#/components/schemas/Outcome'
        input:
          $ref: '#/components/schemas/ValidationInput'
        ruleset:
          $ref: '#/components/schemas/RulesetSelection'
        profile:
          $ref: '#/components/schemas/ProfileDetection'
        layers:
          type: array
          description: Every layer, always in this order, always present.
          minItems: 5
          maxItems: 5
          prefixItems:
            - allOf:
                - $ref: '#/components/schemas/LayerResult'
                - properties: { layer: { const: input } }
            - allOf:
                - $ref: '#/components/schemas/LayerResult'
                - properties: { layer: { const: xml } }
            - allOf:
                - $ref: '#/components/schemas/LayerResult'
                - properties: { layer: { const: xsd } }
            - allOf:
                - $ref: '#/components/schemas/LayerResult'
                - properties: { layer: { const: en16931 } }
            - allOf:
                - $ref: '#/components/schemas/LayerResult'
                - properties: { layer: { const: peppol } }
          items: false
        counts:
          $ref: '#/components/schemas/FindingCounts'
        findings:
          type: array
          description: Normalised findings in deterministic order. Empty, never null.
          maxItems: 1000
          items:
            $ref: '#/components/schemas/Finding'
        findings_summary:
          $ref: '#/components/schemas/FindingsSummary'
        timing:
          $ref: '#/components/schemas/ValidationTiming'
        engine:
          $ref: '#/components/schemas/EngineInfo'
        links:
          $ref: '#/components/schemas/ResultLinks'
        usage:
          $ref: '#/components/schemas/Usage'
      allOf:
        # status and outcome agree: completed <=> a verdict; error <=> indeterminate.
        - if:
            properties: { status: { const: completed } }
          then:
            properties: { outcome: { enum: [valid, invalid] } }
          else:
            properties: { outcome: { const: indeterminate } }
        # valid: nothing failed or errored, and no fatal finding was counted.
        - if:
            properties: { outcome: { const: valid } }
          then:
            properties:
              layers:
                items:
                  properties: { status: { enum: [passed, skipped] } }
              counts:
                properties:
                  by_severity:
                    properties: { fatal: { const: 0 } }
        # invalid: at least one layer failed, none errored, and at least one
        # fatal finding exists - warnings alone never make a document invalid.
        - if:
            properties: { outcome: { const: invalid } }
          then:
            properties:
              layers:
                contains:
                  properties: { status: { const: failed } }
                items:
                  properties: { status: { enum: [passed, failed, skipped] } }
              counts:
                properties:
                  by_severity:
                    properties: { fatal: { minimum: 1 } }
        # indeterminate: at least one layer errored, and nothing was charged.
        - if:
            properties: { outcome: { const: indeterminate } }
          then:
            properties:
              layers:
                contains:
                  properties: { status: { const: error } }
              usage:
                properties: { charged: { const: false } }
        # Authenticated-only fields all hang off the operation record.
        - if:
            anyOf:
              - required: [usage]
              - required: [document_id]
              - required: [links]
          then:
            required: [operation_id]
        # Selection relationships. An exact selector names the release and
        # its state may be any validatable one; latest/detected always
        # resolve the sendable release; the profile source follows the
        # selection method.
        - if:
            properties: { ruleset: { properties: { selection_method: { const: exact } } } }
          then:
            properties:
              ruleset:
                properties:
                  requested: { pattern: '^fwrs_' }
                  state: { enum: [scheduled, sendable, historical] }
              profile:
                properties: { source: { const: requested } }
        - if:
            properties: { ruleset: { properties: { requested: { pattern: '^fwrs_' } } } }
          then:
            properties: { ruleset: { properties: { selection_method: { const: exact } } } }
        - if:
            properties: { ruleset: { properties: { selection_method: { enum: [latest, detected] } } } }
          then:
            properties:
              ruleset:
                properties:
                  requested: { const: latest }
                  state: { const: sendable }
        - if:
            properties: { ruleset: { properties: { selection_method: { const: latest } } } }
          then:
            properties: { profile: { properties: { source: { const: requested } } } }
        - if:
            properties: { ruleset: { properties: { selection_method: { const: detected } } } }
          then:
            properties: { profile: { properties: { source: { const: detected } } } }
        # A hint is only ever produced by a hint pack, which the result names.
        - if:
            properties: { findings: { contains: { required: [hint] } } }
          then:
            properties: { engine: { required: [hint_pack_version] } }

    ValidationVerdict:
      description: >-
        The `200` response of the validate operation: a `ValidationResult`
        that reached a verdict. `status` is `completed`, `outcome` is
        `valid` or `invalid`, and no layer is in `error`. Indeterminate
        results are not representable here; they are Problems.
      allOf:
        - $ref: '#/components/schemas/ValidationResult'
        - properties:
            status: { const: completed }
            outcome: { enum: [valid, invalid] }
            layers:
              items:
                properties: { status: { enum: [passed, failed, skipped] } }

    ValidationInput:
      type: object
      additionalProperties: false
      required: [sha256, bytes, content_type, document_type]
      properties:
        sha256:
          $ref: '#/components/schemas/SHA256'
          description: SHA-256 of the exact bytes received.
        bytes:
          type: integer
          minimum: 0
          maximum: 5242880
        content_type:
          type: string
          enum: [application/xml, text/xml]
        document_type:
          type: string
          description: The document type found at the root element; `unknown` when the root was not recognised or the XML did not parse.
          enum: [invoice, credit_note, unknown]

    RulesetSelection:
      type: object
      description: What was asked for and what was used.
      additionalProperties: false
      required: [requested, id, selection_method, state]
      properties:
        requested:
          $ref: '#/components/schemas/RulesetSelector'
          description: The selector the request carried (defaulted to `latest` if absent). An exact selector equals `id`.
        id:
          $ref: '#/components/schemas/RulesetID'
          description: The immutable ruleset the document was validated against.
        selection_method:
          $ref: '#/components/schemas/SelectionMethod'
        state:
          $ref: '#/components/schemas/RulesetState'
          description: The release's state at validation time. `latest` and `detected` always report `sendable`; `exact` reports whichever validatable state the release is in.
        vesid:
          type: string
          description: The engine's coordinate for the release, for cross-referencing official tooling.
          maxLength: 128
        official_release:
          type: string
          maxLength: 128

    ProfileDetection:
      type: object
      description: The profile family the document was validated as, and how that was decided.
      additionalProperties: false
      required: [family, source]
      properties:
        family:
          $ref: '#/components/schemas/ProfileFamily'
        source:
          type: string
          description: "`requested` (the request named the profile or an exact ruleset) or `detected` (from the document's identifiers)."
          enum: [requested, detected]
        customization_id:
          type: string
          description: The document's `CustomizationID`, a profile identifier - not invoice content. Present when parsed.
          maxLength: 256
        profile_id:
          type: string
          description: The document's `ProfileID`. Present when parsed.
          maxLength: 256

    LayerResult:
      type: object
      additionalProperties: false
      required: [layer, status, fatal, warning]
      properties:
        layer:
          $ref: '#/components/schemas/Layer'
        status:
          $ref: '#/components/schemas/LayerStatus'
        reason:
          $ref: '#/components/schemas/LayerReason'
          description: Required when `status` is `skipped` or `error`; absent otherwise.
        fatal:
          type: integer
          minimum: 0
          description: Fatal findings this layer produced.
        warning:
          type: integer
          minimum: 0
      allOf:
        # A layer that did not run says why, and counted nothing.
        - if:
            properties: { status: { enum: [skipped, error] } }
          then:
            required: [reason]
            properties:
              fatal: { const: 0 }
              warning: { const: 0 }
          else:
            not: { required: [reason] }
        # A layer fails exactly when it produced a fatal finding; warnings
        # alone never fail a layer.
        - if:
            properties: { status: { const: failed } }
          then:
            properties: { fatal: { minimum: 1 } }
        - if:
            properties: { status: { const: passed } }
          then:
            properties: { fatal: { const: 0 } }

    FindingCounts:
      type: object
      additionalProperties: false
      required: [by_severity, by_layer]
      properties:
        by_severity:
          type: object
          additionalProperties: false
          required: [fatal, warning]
          properties:
            fatal: { type: integer, minimum: 0 }
            warning: { type: integer, minimum: 0 }
        by_layer:
          type: object
          additionalProperties: false
          required: [input, xml, xsd, en16931, peppol]
          properties:
            input: { type: integer, minimum: 0 }
            xml: { type: integer, minimum: 0 }
            xsd: { type: integer, minimum: 0 }
            en16931: { type: integer, minimum: 0 }
            peppol: { type: integer, minimum: 0 }

    Finding:
      type: object
      description: >-
        One normalised rule result. The official rule id and severity are
        preserved; message and location are bounded and never carry invoice
        values. Hints are Ironfang's own, versioned by
        `engine.hint_pack_version`, and may be absent.
      additionalProperties: false
      required: [severity, layer, rule_id, message, location_kind, ruleset_id]
      properties:
        severity:
          $ref: '#/components/schemas/Severity'
        layer:
          $ref: '#/components/schemas/Layer'
        rule_id:
          type: string
          description: The official rule identifier, e.g. `PEPPOL-EN16931-R020`, `BR-CO-10`, or `cvc-complex-type.2.4.a` for schema errors.
          minLength: 1
          maxLength: 64
          pattern: '^[A-Za-z0-9._:-]+$'
        message:
          $ref: '#/components/schemas/FindingMessage'
        location:
          type: string
          description: A bounded, sanitised location. Absent when `location_kind` is `none`.
          minLength: 1
          maxLength: 256
        location_kind:
          $ref: '#/components/schemas/LocationKind'
        hint_code:
          type: string
          pattern: '^[a-z0-9-]{1,80}$'
        hint:
          type: string
          minLength: 1
          maxLength: 512
        source_url:
          $ref: '#/components/schemas/HTTPSURL'
          description: The official specification or rule source for this rule, when known.
        ruleset_id:
          $ref: '#/components/schemas/RulesetID'
      allOf:
        - if:
            properties: { location_kind: { const: none } }
          then:
            not: { required: [location] }
          else:
            required: [location]
        - if:
            required: [hint]
          then:
            required: [hint_code]

    FindingsSummary:
      type: object
      description: >-
        Truncation is explicit. `returned` is the length of `findings`;
        `total` is how many exist; `cap` is the inline maximum; `truncated`
        is `total > returned`, and a truncated result returns exactly `cap`.
      additionalProperties: false
      required: [returned, total, cap, truncated]
      properties:
        returned:
          type: integer
          minimum: 0
          maximum: 1000
        total:
          type: integer
          minimum: 0
        cap:
          type: integer
          const: 1000
        truncated:
          type: boolean
      allOf:
        - if:
            properties: { truncated: { const: true } }
          then:
            properties: { returned: { const: 1000 } }

    ValidationTiming:
      type: object
      additionalProperties: false
      required: [started_at, completed_at, duration_ms]
      properties:
        started_at:
          $ref: '#/components/schemas/Timestamp'
        completed_at:
          $ref: '#/components/schemas/Timestamp'
        duration_ms:
          type: integer
          minimum: 0
          maximum: 10000
          description: Wall time, bounded by the 10-second deadline.

    EngineInfo:
      type: object
      description: What ran, exactly, so the result can be reproduced.
      additionalProperties: false
      required: [name, image_digest]
      properties:
        name:
          type: string
          description: The validator engine and version, e.g. `phive/12.1.0`.
          minLength: 1
          maxLength: 64
        image_digest:
          $ref: '#/components/schemas/ImageDigest'
        hint_pack_version:
          type: string
          description: The version of Ironfang's hint mappings applied, when any. Nonblank when present.
          minLength: 1
          maxLength: 32

    ResultLinks:
      type: object
      description: Authorised links, present only on authenticated results whose retention policy keeps them. Time-limited.
      additionalProperties: false
      properties:
        report:
          $ref: '#/components/schemas/HTTPSURL'
        evidence:
          $ref: '#/components/schemas/HTTPSURL'

    Usage:
      type: object
      description: >-
        Present only on authenticated results. Never reported for anonymous
        validation, which has no entitlement to report against.
      additionalProperties: false
      required: [charged, credits]
      properties:
        charged:
          type: boolean
          description: Whether this validation consumed entitlement. Always `false` for indeterminate outcomes.
        credits:
          type: integer
          minimum: 0
          description: Credits consumed by this validation.
        remaining:
          type: integer
          minimum: 0
          description: Credits left in the current period, when the plan is metered.
        period_ends_at:
          $ref: '#/components/schemas/Timestamp'

    # ---------------------------------------------------------------- problem
    Problem:
      type: object
      description: >-
        An RFC 9457 problem. `code` is the stable, machine-readable Ironfang
        identifier; `type` is its URL and `status` its one HTTP status, both
        fixed per code below. `detail` is safe to show and never carries
        parser output, invoice content or stack traces. Validation findings
        are never Problems.
      additionalProperties: false
      required: [type, title, status, detail, code, request_id]
      properties:
        type:
          type: string
          format: uri
          pattern: '^https://ironfang\.uk/problems/[a-z_]+$'
        title:
          type: string
          minLength: 1
          maxLength: 128
        status:
          type: integer
          minimum: 400
          maximum: 599
        detail:
          type: string
          minLength: 1
          maxLength: 512
        instance:
          type: string
          format: uri-reference
          maxLength: 256
        code:
          type: string
          description: The stable Ironfang problem code.
          enum:
            - malformed_request
            - unauthenticated
            - insufficient_scope
            - idempotency_conflict
            - payload_too_large
            - unsupported_content_type
            - unknown_ruleset
            - ruleset_disabled
            - result_gone
            - unsupported_profile
            - profile_undetected
            - unsupported_document_type
            - not_found
            - rate_limited
            - quota_exhausted
            - validation_timeout
            - validator_unavailable
            - internal_error
        request_id:
          $ref: '#/components/schemas/RequestID'
        errors:
          type: array
          description: Field-level errors, on `malformed_request` only. Bounded; empty is omitted.
          minItems: 1
          maxItems: 50
          items:
            $ref: '#/components/schemas/FieldError'
        operation_id:
          $ref: '#/components/schemas/OpaqueID'
          description: On an indeterminate validation problem only, the operation record that was opened, if any.
        outcome:
          type: string
          const: indeterminate
        usage_charged:
          type: boolean
          const: false
        retry_after_seconds:
          type: integer
          minimum: 1
          maximum: 3600
          description: Only on problems that invite a retry (`rate_limited`, `quota_exhausted`, `validator_unavailable`).
      allOf:
        # Each code has exactly one status and one type.
        - if: { properties: { code: { const: malformed_request } } }
          then: { properties: { status: { const: 400 }, type: { const: 'https://ironfang.uk/problems/malformed_request' } } }
        - if: { properties: { code: { const: unauthenticated } } }
          then: { properties: { status: { const: 401 }, type: { const: 'https://ironfang.uk/problems/unauthenticated' } } }
        - if: { properties: { code: { const: insufficient_scope } } }
          then: { properties: { status: { const: 403 }, type: { const: 'https://ironfang.uk/problems/insufficient_scope' } } }
        - if: { properties: { code: { const: not_found } } }
          then: { properties: { status: { const: 404 }, type: { const: 'https://ironfang.uk/problems/not_found' } } }
        - if: { properties: { code: { const: idempotency_conflict } } }
          then: { properties: { status: { const: 409 }, type: { const: 'https://ironfang.uk/problems/idempotency_conflict' } } }
        - if: { properties: { code: { const: result_gone } } }
          then: { properties: { status: { const: 410 }, type: { const: 'https://ironfang.uk/problems/result_gone' } } }
        - if: { properties: { code: { const: ruleset_disabled } } }
          then: { properties: { status: { const: 410 }, type: { const: 'https://ironfang.uk/problems/ruleset_disabled' } } }
        - if: { properties: { code: { const: payload_too_large } } }
          then: { properties: { status: { const: 413 }, type: { const: 'https://ironfang.uk/problems/payload_too_large' } } }
        - if: { properties: { code: { const: unsupported_content_type } } }
          then: { properties: { status: { const: 415 }, type: { const: 'https://ironfang.uk/problems/unsupported_content_type' } } }
        - if: { properties: { code: { const: unknown_ruleset } } }
          then: { properties: { status: { const: 422 }, type: { const: 'https://ironfang.uk/problems/unknown_ruleset' } } }
        - if: { properties: { code: { const: unsupported_profile } } }
          then: { properties: { status: { const: 422 }, type: { const: 'https://ironfang.uk/problems/unsupported_profile' } } }
        - if: { properties: { code: { const: profile_undetected } } }
          then: { properties: { status: { const: 422 }, type: { const: 'https://ironfang.uk/problems/profile_undetected' } } }
        - if: { properties: { code: { const: unsupported_document_type } } }
          then: { properties: { status: { const: 422 }, type: { const: 'https://ironfang.uk/problems/unsupported_document_type' } } }
        - if: { properties: { code: { const: rate_limited } } }
          then: { properties: { status: { const: 429 }, type: { const: 'https://ironfang.uk/problems/rate_limited' } } }
        - if: { properties: { code: { const: quota_exhausted } } }
          then: { properties: { status: { const: 429 }, type: { const: 'https://ironfang.uk/problems/quota_exhausted' } } }
        - if: { properties: { code: { const: internal_error } } }
          then: { properties: { status: { const: 500 }, type: { const: 'https://ironfang.uk/problems/internal_error' } } }
        - if: { properties: { code: { const: validator_unavailable } } }
          then: { properties: { status: { const: 503 }, type: { const: 'https://ironfang.uk/problems/validator_unavailable' } } }
        - if: { properties: { code: { const: validation_timeout } } }
          then: { properties: { status: { const: 504 }, type: { const: 'https://ironfang.uk/problems/validation_timeout' } } }
        # Timeout and unavailability are always indeterminate. internal_error
        # is indeterminate in a validation context (the operation's response
        # schema requires the markers) and carries none on a registry read
        # (that response schema forbids them).
        - if:
            properties: { code: { enum: [validation_timeout, validator_unavailable] } }
          then:
            required: [outcome, usage_charged]
        - if:
            not:
              properties: { code: { enum: [validation_timeout, validator_unavailable, internal_error] } }
          then:
            not:
              anyOf:
                - required: [outcome]
                - required: [usage_charged]
                - required: [operation_id]
        # The markers travel together, and an operation id needs them.
        - if:
            anyOf:
              - required: [outcome]
              - required: [usage_charged]
              - required: [operation_id]
          then:
            required: [outcome, usage_charged]
        # Field errors belong to malformed requests only.
        - if:
            required: [errors]
          then:
            properties: { code: { const: malformed_request } }
        # Only problems that invite a retry say when.
        - if:
            required: [retry_after_seconds]
          then:
            properties: { code: { enum: [rate_limited, quota_exhausted, validator_unavailable] } }

    FieldError:
      type: object
      additionalProperties: false
      required: [field, code, message]
      properties:
        field:
          type: string
          description: The option or parameter at fault, e.g. `ruleset`, `options.document_type`.
          minLength: 1
          maxLength: 128
        code:
          type: string
          enum: [required, invalid_value, unknown_field, too_long]
        message:
          type: string
          minLength: 1
          maxLength: 256

    # --------------------------------------------------------------- rulesets
    RulesetList:
      type: object
      additionalProperties: false
      required: [schema, rulesets]
      properties:
        schema:
          type: string
          const: financewolf/einvoice/ruleset-list/v1
        rulesets:
          type: array
          description: Empty, never null.
          items:
            $ref: '#/components/schemas/Ruleset'

    Ruleset:
      type: object
      description: >-
        The customer-safe identity of one immutable ruleset: everything
        needed to select it and to reproduce a validation that named it.
        Internal storage, review records and operator identities are not
        part of this contract.
      additionalProperties: false
      required:
        - schema
        - id
        - state
        - latest
        - profile_family
        - document_type
        - syntax
        - official_release
        - en16931_release
        - bis_release
        - vesid
        - engine
        - valid_from
        - valid_until
        - sources
        - artifacts
      properties:
        schema:
          type: string
          const: financewolf/einvoice/ruleset/v1
        id:
          $ref: '#/components/schemas/RulesetID'
        state:
          $ref: '#/components/schemas/RulesetState'
        latest:
          type: boolean
          description: "True on the one `sendable` release `ruleset: latest` currently resolves to for this profile family and document type."
        profile_family:
          $ref: '#/components/schemas/ProfileFamily'
        document_type:
          $ref: '#/components/schemas/DocumentType'
        syntax:
          type: string
          enum: [UBL 2.1]
        official_release:
          type: string
          description: The standards body's release name.
          minLength: 1
          maxLength: 128
        en16931_release:
          type: string
          minLength: 1
          maxLength: 32
        bis_release:
          type: string
          minLength: 1
          maxLength: 32
        vesid:
          type: string
          minLength: 1
          maxLength: 128
        engine:
          $ref: '#/components/schemas/EngineInfo'
        valid_from:
          $ref: '#/components/schemas/Timestamp'
          description: When the official release takes effect. Part of the release's identity; never changes.
        valid_until:
          description: The official end of validity, or null when open-ended. Never changes.
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        published_on:
          description: The standards body's publication date, when known.
          oneOf:
            - type: string
              format: date
            - type: 'null'
        retired_at:
          description: When Ironfang stopped sending against this release. Distinct from the official window.
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        replaced_by:
          description: The release that superseded this one, when retired.
          oneOf:
            - $ref: '#/components/schemas/RulesetID'
            - type: 'null'
        sources:
          type: array
          description: Official sources for the release. Never empty.
          minItems: 1
          items:
            $ref: '#/components/schemas/HTTPSURL'
        artifacts:
          type: array
          description: Exactly one checksummed rule artefact per layer - xsd, en16931 and peppol.
          minItems: 3
          maxItems: 3
          items:
            $ref: '#/components/schemas/RulesetArtifact'
          allOf:
            - contains: { properties: { layer: { const: xsd } } }
            - contains: { properties: { layer: { const: en16931 } } }
            - contains: { properties: { layer: { const: peppol } } }
      allOf:
        - if:
            properties: { latest: { const: true } }
          then:
            properties: { state: { const: sendable } }
        - if:
            properties: { state: { const: historical } }
          then:
            required: [retired_at]
            properties:
              retired_at: { $ref: '#/components/schemas/Timestamp' }

    RulesetArtifact:
      type: object
      description: One rule artefact's checksum and provenance. Where the files live is not part of the contract.
      additionalProperties: false
      required: [layer, sha256, licence, licence_status, source_url]
      properties:
        layer:
          type: string
          enum: [xsd, en16931, peppol]
        sha256:
          $ref: '#/components/schemas/SHA256'
        licence:
          type: string
          description: The artefact's own licence, as an SPDX identifier or `NOASSERTION`.
          minLength: 1
          maxLength: 64
        licence_status:
          $ref: '#/components/schemas/LicenceStatus'
        source_url:
          $ref: '#/components/schemas/HTTPSURL'
