# GatiFlow API ruleset
#
# The rules the GatiFlow OpenAPI contract is held to, written by GatiFlow and
# published beside the contract it governs:
#
#   spectral lint https://gatiflow.io/openapi.json --ruleset https://gatiflow.io/spectral.yaml
#
# It extends every rule of spectral:oas and adds the decisions that are ours:
# versioned kebab-case paths, a read-only surface, a credential stated on
# every operation and never sent in a query string, one error envelope, an
# x-agentic-access classification on every operation, a signed webhook, and
# an authorship marker on the contract itself.
#
# error is a decision the contract may not break. warn is a convention a
# change has to argue against. info is a preference.
#
# The contract passes it with no findings at any severity. The test suite
# runs it on every change (web/tests/unit/spectral-ruleset.test.ts), and for
# each rule it also lints a copy of the contract broken in the one way that
# rule exists to catch, so no rule here passes because it matches nothing.

x-provenance:
  method: published
  publisher: GatiFlow
  source: https://gatiflow.io/spectral.yaml
  governs: https://gatiflow.io/openapi.json

extends:
  - [spectral:oas, all]

formats:
  - oas3

aliases:
  Operation:
    - "$.paths[*][get,put,post,delete,options,head,patch,trace]"
  WebhookOperation:
    - "$.webhooks[*][get,put,post,delete,options,head,patch,trace]"

rules:
  # ------------------------------------------------------------------ error

  gatiflow-openapi-3-1:
    description: The contract is OpenAPI 3.1, the version its webhooks section and JSON Schema 2020-12 types need.
    message: "The contract declares OpenAPI {{value}}, not 3.1."
    severity: error
    given: "$.openapi"
    then:
      function: pattern
      functionOptions:
        match: "^3\\.1\\.\\d+$"

  gatiflow-server-production:
    description: >-
      The contract names exactly one server, https://api.gatiflow.io. It is served from gatiflow.io, so without
      this every path would resolve against the marketing site.
    message: "servers must be exactly [https://api.gatiflow.io]: {{error}}"
    severity: error
    given: "$"
    then:
      field: servers
      function: schema
      functionOptions:
        schema:
          type: array
          minItems: 1
          maxItems: 1
          items:
            type: object
            required: [url]
            properties:
              url:
                const: https://api.gatiflow.io

  gatiflow-path-versioned:
    description: >-
      The version is in the path. Every operation lives under /api/v1, and a breaking change ships under a new
      version path next to the current one.
    message: "{{property}} is not under /api/v1/."
    severity: error
    given: "$.paths"
    then:
      field: "@key"
      function: pattern
      functionOptions:
        match: "^/api/v1/"

  gatiflow-path-kebab-case:
    description: Path segments are lowercase kebab-case, and path parameters are snake_case in braces.
    message: "{{property}} has a segment that is not kebab-case."
    severity: error
    given: "$.paths"
    then:
      field: "@key"
      function: pattern
      functionOptions:
        match: "^(/([a-z0-9]+(-[a-z0-9]+)*|\\{[a-z][a-z0-9_]*\\}))+$"

  gatiflow-read-only:
    description: >-
      Every published operation is a read. A write first needs an idempotency key, an x-agentic-access
      classification with its consequence and human-in-the-loop decision, and a reversal story, so its first
      appearance fails here until this rule is changed on purpose.
    message: "A {{property}} operation was published; the API is read-only."
    severity: error
    given: "$.paths[*][put,post,delete,patch]"
    then:
      function: falsy

  gatiflow-operation-security-explicit:
    description: >-
      Every operation states its credential. The document sets no default, so an operation without a security
      key would read as open; one that takes nothing says so with an empty list.
    message: "The operation does not state which credential it takes."
    severity: error
    given: "#Operation"
    then:
      field: security
      function: defined

  gatiflow-security-known-schemes:
    description: >-
      An operation takes one of the two credentials the contract defines, ApiKeyAuth or SessionBearer, alone.
    message: "{{error}}"
    severity: error
    given: "$.paths[*][*].security[*]"
    then:
      function: schema
      functionOptions:
        schema:
          type: object
          minProperties: 1
          maxProperties: 1
          propertyNames:
            enum: [ApiKeyAuth, SessionBearer]

  gatiflow-api-key-in-header:
    description: The API key travels in the X-API-Key header, never in the URL, where logs and proxies keep it.
    message: "The API key scheme must be in: header, name: X-API-Key."
    severity: error
    given: "$.components.securitySchemes[?(@.type == 'apiKey')]"
    then:
      - field: in
        function: enumeration
        functionOptions:
          values: [header]
      - field: name
        function: enumeration
        functionOptions:
          values: [X-API-Key]

  gatiflow-no-credentials-in-query:
    description: No query parameter carries a credential.
    message: "Query parameter {{value}} looks like a credential."
    severity: error
    given: "$.paths[*][*].parameters[?(@.in == 'query')].name"
    then:
      function: pattern
      functionOptions:
        notMatch: "/(api[-_]?key|token|secret|password|session)/i"

  gatiflow-unauthorized-documented:
    description: An operation that takes a credential documents the 401 a missing or wrong one receives.
    message: "The operation takes a credential and documents no 401."
    severity: error
    given: "#Operation"
    then:
      function: schema
      functionOptions:
        schema:
          if:
            required: [security]
            properties:
              security:
                type: array
                minItems: 1
          then:
            required: [responses]
            properties:
              responses:
                required: ["401"]

  gatiflow-error-envelope:
    description: >-
      Every 4xx and 5xx is the one error envelope, ErrorResponse, or StaleDataError, the same envelope with a
      structured message.
    message: "An error response does not reference ErrorResponse or StaleDataError."
    severity: error
    resolved: false
    given: "$.paths[*][*].responses[?(@property.match(/^[45]/))].content.application/json.schema"
    then:
      - field: "$ref"
        function: truthy
      - field: "$ref"
        function: pattern
        functionOptions:
          match: "^#/components/schemas/(ErrorResponse|StaleDataError)$"

  gatiflow-stale-data-retry-after:
    description: The 503 that refuses stale data says when to ask again, in a Retry-After header.
    message: "A StaleDataError response declares no Retry-After header."
    severity: error
    resolved: false
    given: "$.paths[*][*].responses[*]"
    then:
      function: schema
      functionOptions:
        schema:
          if:
            required: [content]
            properties:
              content:
                required: [application/json]
                properties:
                  application/json:
                    required: [schema]
                    properties:
                      schema:
                        required: ["$ref"]
                        properties:
                          "$ref":
                            const: "#/components/schemas/StaleDataError"
          then:
            required: [headers]
            properties:
              headers:
                required: [Retry-After]

  gatiflow-rate-limited-documents-429:
    description: An operation that reports X-RateLimit-* headers documents the 429 its limit produces.
    message: "The operation reports X-RateLimit-* headers and documents no 429."
    severity: error
    given: "#Operation"
    then:
      function: schema
      functionOptions:
        schema:
          if:
            required: [responses]
            properties:
              responses:
                required: ["200"]
                properties:
                  "200":
                    required: [headers]
                    properties:
                      headers:
                        required: [X-RateLimit-Limit]
          then:
            properties:
              responses:
                required: ["429"]

  gatiflow-deprecation-announced:
    description: >-
      A deprecated operation announces it on every success, with a Deprecation header (RFC 9745) and a Sunset
      header (RFC 8594) carrying the removal date, as the versioning policy promises.
    message: "A deprecated operation is missing the Deprecation or Sunset header on a 2xx response."
    severity: error
    given: "#Operation"
    then:
      function: schema
      functionOptions:
        schema:
          if:
            required: [deprecated]
            properties:
              deprecated:
                const: true
          then:
            required: [responses]
            properties:
              responses:
                patternProperties:
                  "^2":
                    required: [headers]
                    properties:
                      headers:
                        required: [Deprecation, Sunset]

  gatiflow-agentic-access-declared:
    description: >-
      Every operation carries its x-agentic-access classification, so an agent reads what a call may do from
      the contract it already fetched.
    message: "{{error}}"
    severity: error
    given: "#Operation"
    then:
      - field: x-agentic-access
        function: truthy
      - field: x-agentic-access
        function: schema
        functionOptions:
          schema:
            type: object
            required: [action-class, consequence, subject, audience, escalation, audit]
            properties:
              action-class:
                enum: [connected, acting]
              consequence:
                enum: [read, write, physical, safety-critical]
              subject:
                enum: [optional, required]
              audience:
                const: https://api.gatiflow.io
              escalation:
                type: object
                required: [human-in-the-loop]
                properties:
                  human-in-the-loop:
                    enum: [none, conditional, required]
              audit:
                enum: [none, required]

  gatiflow-get-is-read:
    description: A GET changes nothing, so its x-agentic-access consequence is read.
    message: "A GET is classified with consequence {{value}}."
    severity: error
    given: "$.paths[*].get.x-agentic-access.consequence"
    then:
      function: enumeration
      functionOptions:
        values: [read]

  gatiflow-webhook-signed:
    description: >-
      Every event GatiFlow sends is signed. The webhook declares X-GatiFlow-Signature as a required header,
      so a receiver's generated code verifies it.
    message: "The webhook does not declare the required X-GatiFlow-Signature header."
    severity: error
    given: "#WebhookOperation"
    then:
      function: schema
      functionOptions:
        schema:
          required: [parameters]
          properties:
            parameters:
              type: array
              contains:
                type: object
                required: [name, in, required]
                properties:
                  name:
                    const: X-GatiFlow-Signature
                  in:
                    const: header
                  required:
                    const: true

  gatiflow-provenance-declared:
    description: >-
      The contract names its author: x-provenance with method published, publisher GatiFlow and the gatiflow.io
      URL it is served at, the marker a catalog reads to tell a provider's contract from one written about it.
    message: "{{error}}"
    severity: error
    given: "$"
    then:
      - field: x-provenance
        function: truthy
      - field: x-provenance
        function: schema
        functionOptions:
          schema:
            type: object
            required: [method, publisher, source]
            properties:
              method:
                const: published
              publisher:
                const: GatiFlow
              source:
                type: string
                pattern: "^https://gatiflow\\.io/"

  # ------------------------------------------------------------------- warn

  gatiflow-operation-single-tag:
    description: Each operation is filed under exactly one tag, which is also its folder in the Postman collection.
    message: "The operation has {{error}}"
    severity: warn
    given: "#Operation"
    then:
      field: tags
      function: length
      functionOptions:
        min: 1
        max: 1

  gatiflow-operation-description-substantive:
    description: >-
      An operation description says what the call returns, what it costs in quota and how it refuses, which
      does not fit in fewer than 100 characters.
    message: "The operation description is shorter than 100 characters."
    severity: warn
    given: "#Operation"
    then:
      field: description
      function: length
      functionOptions:
        min: 100

  gatiflow-path-params-snake-case:
    description: Path parameter names are snake_case, like the fields they come from.
    message: "Path parameter {{value}} is not snake_case."
    severity: warn
    given: "$.paths[*][*].parameters[?(@.in == 'path')].name"
    then:
      function: casing
      functionOptions:
        type: snake

  gatiflow-query-params-snake-case:
    description: Query parameter names are snake_case.
    message: "Query parameter {{value}} is not snake_case."
    severity: warn
    given: "$.paths[*][*].parameters[?(@.in == 'query')].name"
    then:
      function: casing
      functionOptions:
        type: snake

  gatiflow-properties-snake-case:
    description: Response field names are snake_case, the way every payload spells them.
    message: "Field {{property}} is not snake_case."
    severity: warn
    given: "$.components.schemas..properties"
    then:
      field: "@key"
      function: casing
      functionOptions:
        type: snake

  gatiflow-schema-described:
    description: Every component schema says what it is in a description, not only a title derived from its name.
    message: "The schema has no description."
    severity: warn
    given: "$.components.schemas[*]"
    then:
      field: description
      function: truthy

  gatiflow-success-example:
    description: Every JSON success response carries a worked example, the empty states included.
    message: "A JSON success response carries no example."
    severity: warn
    given: "$.paths[*][*].responses[?(@property.match(/^2/))].content.application/json"
    then:
      function: schema
      functionOptions:
        schema:
          anyOf:
            - required: [examples]
            - required: [example]

  gatiflow-contact-email:
    description: The contract's contact is a gatiflow.io address someone reads.
    message: "info.contact.email is not a gatiflow.io address."
    severity: warn
    given: "$.info.contact"
    then:
      field: email
      function: pattern
      functionOptions:
        match: "@gatiflow\\.io$"

  # ------------------------------------------------------------------- info

  gatiflow-summary-short:
    description: An operation summary fits on one line of a sidebar, in 60 characters, with no closing period.
    message: "{{error}}"
    severity: info
    given: "$.paths[*][*].summary"
    then:
      - function: length
        functionOptions:
          max: 60
      - function: pattern
        functionOptions:
          notMatch: "\\.$"

  gatiflow-schema-names-pascal-case:
    description: Component schema names are PascalCase nouns.
    message: "Schema {{property}} is not PascalCase."
    severity: info
    given: "$.components.schemas"
    then:
      field: "@key"
      function: pattern
      functionOptions:
        match: "^[A-Z][A-Za-z0-9]*$"

  gatiflow-webhook-headers-namespaced:
    description: The headers GatiFlow sends with a webhook carry the X-GatiFlow- prefix.
    message: "Webhook header {{value}} does not start with X-GatiFlow-."
    severity: info
    given: "$.webhooks[*][*].parameters[?(@.in == 'header')].name"
    then:
      function: pattern
      functionOptions:
        match: "^X-GatiFlow-"
