openapi: 3.1.0

info:
  title: SafeNode Evaluate API
  version: "1.0.0"
  summary: Policy firewall for AI agent actions.
  description: |
    SafeNode evaluates a proposed AI agent action against your organization's policies and returns
    a decision: `allow`, `warn`, `review`, or `deny`.

    The pattern is **evaluate first, act second**. Your code branches on `decision` and logs `trace_id`.

    ## Notes that are easy to get wrong

    - `impact_score` and `risk_score` are on a **0-100** scale, not 0-1.
    - `trace_id` is a bare UUID v4, with no prefix.
    - `alternatives` is **never empty** — a `general` suggestion is always appended.
    - **Two different conditions return `429`.** A throttle 429 carries `Retry-After` and is
      retryable. A plan-cap 429 carries `evaluations_cap` in the body, has no `Retry-After`, and is
      **not** retryable until the next billing month. Discriminate on the presence of
      `evaluations_cap`.
    - Every call is a **write** (it creates an evaluation record and counts against your monthly cap).
      It is not idempotent. Do not retry a request that timed out — it may already have been recorded.
    - `action_type` is free-form. There is no server-side enum.

    This document is generated from the implementation, not from prose. See `sdk/API-CONTRACT.md`
    in the SafeNode repository for the line-by-line derivation.
  contact:
    name: SafeNode
    url: https://safenode.tech
  license:
    name: MIT
    identifier: MIT

servers:
  - url: https://safenode.tech
    description: Production

security:
  - bearerAuth: []
  - apiKeyAuth: []

tags:
  - name: Evaluate
    description: Policy evaluation for agent actions.

paths:
  /api/v1/evaluate:
    post:
      operationId: evaluateAction
      tags: [Evaluate]
      summary: Evaluate a proposed agent action against policy
      description: |
        Submits a structured description of an action an agent is about to take. Returns a policy
        decision plus scores, reasons, and suggested alternatives.

        The enforcing organization is always derived from the API key. Caller-supplied
        `organization_id` / `safenode_organization_id` fields are ignored.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EvaluateRequest'
            examples:
              minimal:
                summary: Minimal — action_type only
                value:
                  action_type: send_email
              withPayload:
                summary: With payload
                value:
                  action_type: send_email
                  payload:
                    to: user@example.com
                    subject: Your order
                    body: "Order #123 has shipped."
              withContext:
                summary: With context (recommended)
                description: |
                  Use `cost_estimate` (or `spend`) for spend gating. `cost_usd` is not read by any rule.
                value:
                  action_type: call_model
                  payload:
                    model: gpt-4
                    tokens: 500
                  context:
                    region: eu-west-1
                    vendor_id: openai
                    cost_estimate: 0.02
              mcpToolCall:
                summary: MCP gateway tool call
                value:
                  action_type: mcp_tool_call
                  payload:
                    path: /etc/passwd
                  context:
                    tool_name: read_file
                    server_name: filesystem
                    session_id: 6f1b9c40-1f1a-4a7e-9a3b-6d2c9e0f1a22
      responses:
        '200':
          description: Evaluation completed. Check `decision`; a `deny` is a successful 200, not an error.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EvaluateResponse'
              examples:
                allow:
                  summary: Allowed (empty policy)
                  value:
                    decision: allow
                    impact_score: 0
                    risk_score: 0
                    matched_policies: []
                    reasons: []
                    alternatives:
                      - type: general
                        description: Review the policy rules and adjust the request or contact your administrator.
                    trace_id: 9f1c2b7e-3d4a-4c8b-9e10-5a6f7b8c9d0e
                warn:
                  summary: Warned by a soft rule
                  value:
                    decision: warn
                    impact_score: 42.5
                    risk_score: 41.25
                    matched_policies:
                      - rule_id: spend_threshold
                    reasons:
                      - "Estimated cost (5) exceeds threshold (1)."
                    alternatives:
                      - type: cost
                        description: Reduce cost estimate or request a higher spend threshold.
                    trace_id: 3a7d1e55-8c02-4b19-b6a4-0e5f2c8d1b93
                deny:
                  summary: Denied by a hard rule
                  description: |
                    Hard-rule denials hardcode both scores to 100 and return an empty
                    `matched_policies`. Use `reasons` for attribution.
                  value:
                    decision: deny
                    impact_score: 100
                    risk_score: 100
                    matched_policies: []
                    reasons:
                      - 'Vendor "Blocked Co" is blocked.'
                    alternatives:
                      - type: vendor
                        description: Use an approved vendor from your registry.
                    trace_id: c41f8b2a-59d7-4e63-8a10-77b3e9f4d5c6
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/OrganizationNotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/ServerError'

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <key>`. Keys are prefixed `sn_`. Takes precedence over `X-Api-Key`
        when both are present.
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        Alternative to the `Authorization` header. Only consulted when `Authorization` is absent or
        does not match `Bearer <key>`.

  headers:
    XRateLimitLimit:
      description: Requests permitted per minute for this API key.
      schema:
        type: integer
        examples: [60]
    XRateLimitRemaining:
      description: Requests remaining in the current window.
      schema:
        type: integer
        examples: [59]
    RetryAfter:
      description: |
        Seconds to wait before retrying. Present on throttle 429s only — **absent** on plan-cap 429s.
      schema:
        type: integer
        examples: [42]
    XRateLimitReset:
      description: Unix timestamp when the throttle window resets.
      schema:
        type: integer
        examples: [1785000000]

  schemas:

    EvaluateRequest:
      type: object
      required: [action_type]
      additionalProperties: false
      properties:
        action_type:
          type: string
          maxLength: 255
          description: |
            What the agent is about to do. **Free-form** — there is no server-side enum. Rules match
            on exact string equality, so use stable names and keep them consistent across your
            codebase. The values below are conventions, not a closed set.
          examples:
            - send_email
            - call_model
            - mcp_tool_call
            - run_shell_command
            - write_file
            - http_request
        payload:
          type: object
          description: |
            The action's own data. Schema-free — never validated per `action_type`.

            **This is stored server-side in full.** Use the SDK's redaction or `metadata_only` mode
            if the payload may contain secrets or personal data.

            JSON-encoded size must not exceed 262144 bytes (256 KiB).
          additionalProperties: true
          examples:
            - to: user@example.com
              subject: Your order
        context:
          $ref: '#/components/schemas/EvaluationContext'
        agent_id:
          type: string
          maxLength: 255
          description: |
            Optional. Usually omitted — the API key already identifies an agent. Supplying it does
            **not** let you evaluate as a different agent; attribution always uses the key's agent.
      examples:
        - action_type: call_model
          payload:
            model: gpt-4
          context:
            cost_estimate: 0.02

    EvaluationContext:
      type: object
      description: |
        Circumstances of the action. Schema-free, but the rules engine only reads specific keys —
        anything else is inert unless a `context_match` rule targets it.

        JSON-encoded size must not exceed 262144 bytes (256 KiB), checked independently of `payload`.
      additionalProperties: true
      properties:
        vendor_id:
          description: Vendor slug or numeric id. Read by the `blocked_vendor` rule.
          type: [string, integer]
          examples: [openai]
        vendor_slug:
          description: Alias for `vendor_id`.
          type: string
        geo_country:
          description: Country code, case-insensitive. Read by `unapproved_region_geo`.
          type: string
          examples: [US]
        country_code:
          description: Alias for `geo_country`.
          type: string
        region:
          description: Cloud region. Read by `unapproved_region_cloud`.
          type: string
          examples: [eu-west-1]
        cloud_region:
          description: Alias for `region`.
          type: string
        cost_estimate:
          description: |
            Estimated cost of the action. Read by `spend_threshold`.

            Use this key (or `spend`). `cost_usd` is **not** read by any rule.
          type: number
          examples: [0.02]
        spend:
          description: Alias for `cost_estimate`.
          type: number
        org_id:
          description: |
            **Your** tenant id, for `context_match` rules. This is not a SafeNode organization
            selector — the enforcing organization always comes from the API key.
        safenode_payload_mode:
          description: |
            Set by the official SDKs. Which payload treatment produced this request.

            Read by the `redaction_metadata` rule, so a policy can refuse to decide on a scrubbed
            payload (`require_full_payload`) or restrict which modes are acceptable.
          type: string
          enum: [full, redacted, metadata_only]
          default: full
        safenode_redactions:
          description: |
            Set by the official SDKs. Counts of values the client stripped before sending, keyed by
            detected type.

            This exists so client-side redaction does not silently defeat server-side policy. The
            `sensitive_data` rule's `patterns` match payload *values*; once a client redacts them,
            that rule can no longer see them. Reporting the counts lets a `redaction_metadata` rule
            deny "any action containing a card number" without the card number ever being sent.

            Self-reported and therefore only as trustworthy as the client. It raises the floor for
            honest callers; it is not a defence against a hostile one.
          type: object
          additionalProperties:
            type: integer
            minimum: 0
          examples:
            - email: 2
              credit_card: 1

    EvaluateResponse:
      type: object
      required:
        [decision, impact_score, risk_score, matched_policies, reasons, alternatives, trace_id]
      additionalProperties: false
      properties:
        decision:
          $ref: '#/components/schemas/Decision'
        impact_score:
          type: number
          format: double
          minimum: 0
          maximum: 100
          description: |
            Weighted policy impact, **0-100**, 2 decimal places. Hard-rule denials return 100;
            an empty policy returns 0.
          examples: [42.5]
        risk_score:
          type: number
          format: double
          minimum: 0
          maximum: 100
          description: |
            Risk estimate, **0-100**, 2 decimal places.

            Decision bands require **both** `impact_score` and `risk_score` to fall under a band's
            ceiling. Do not threshold on `impact_score` alone.
          examples: [41.25]
        matched_policies:
          type: array
          description: |
            Soft rules that matched. Despite the name, entries carry a rule *type* identifier, not a
            policy id or version.

            **Empty on hard-rule denials** — a `deny` can legitimately return `[]`. Use `reasons`
            for attribution.
          items:
            $ref: '#/components/schemas/MatchedPolicy'
        reasons:
          type: array
          description: Human-readable explanations. Safe to surface to an end user or back to a model.
          items:
            type: string
          examples:
            - ['Estimated cost (5) exceeds threshold (1).']
        alternatives:
          type: array
          description: |
            Suggested safer options. **Never empty** — a `general` suggestion is appended when
            nothing more specific applies, including on `allow`. Deduplicated by `type`.
          minItems: 1
          items:
            $ref: '#/components/schemas/Alternative'
        trace_id:
          type: string
          format: uuid
          description: |
            Unique id for this evaluation, as a bare **UUID v4** — no prefix. Log it; it is the join
            key to the Decision Feed in the dashboard.
          examples: [9f1c2b7e-3d4a-4c8b-9e10-5a6f7b8c9d0e]

    Decision:
      type: string
      enum: [allow, warn, review, deny]
      description: |
        The policy outcome. This enum is closed — unlike `action_type`, all four values are fixed in
        the implementation.

        Suggested enforcement:
          * `allow`  — proceed.
          * `warn`   — proceed if your product policy permits; log it and surface it to the user.
          * `review` — hold until a human approves.
          * `deny`   — do not perform the action.
      examples: [warn]

    MatchedPolicy:
      type: object
      required: [rule_id]
      properties:
        rule_id:
          type: string
          description: Rule type identifier.
          examples:
            - action_type_match
            - action_type_context_threshold
            - action_type_business_hours
            - context_match
            - blocked_vendor
            - unapproved_region_geo
            - unapproved_region_cloud
            - sensitive_data
            - redaction_metadata
            - spend_threshold

    Alternative:
      type: object
      required: [type, description]
      properties:
        type:
          type: string
          enum: [region, vendor, cost, sensitive_data, general]
          description: Category of suggestion. At most one entry per type per response.
        description:
          type: string
          description: Human-readable suggestion.
        payload_snippet:
          type: object
          additionalProperties: true
          description: |
            Optional illustrative fragment. **Omitted entirely** when absent — do not expect a
            `null`.

    Error:
      type: object
      required: [message]
      properties:
        message:
          type: string
          description: Human-readable error description.
      additionalProperties: true

    ValidationError:
      type: object
      required: [message, errors]
      properties:
        message:
          type: string
        errors:
          type: object
          description: Field name to list of messages.
          additionalProperties:
            type: array
            items:
              type: string

    QuotaExceededError:
      type: object
      required: [message, evaluations_used, evaluations_cap]
      description: |
        Returned with status `429` when the organization has exhausted its monthly evaluation
        allowance. **Distinct from throttling** — see the `429` response description.
      properties:
        message:
          type: string
        evaluations_used:
          type: integer
          description: Evaluations consumed this billing month.
        evaluations_cap:
          type: integer
          description: |
            Monthly allowance for the current plan. The presence of this key is the reliable
            discriminator between a quota 429 and a throttle 429.

  responses:

    Unauthorized:
      description: |
        Missing, malformed, invalid, expired, or unbound API key. Not retryable.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missing:
              summary: No credential supplied
              value:
                message: Missing or invalid API key.
            invalid:
              summary: Key not recognised
              value:
                message: Invalid API key.
            expired:
              summary: Key past its expiry date
              value:
                message: API key has expired.
            unbound:
              summary: Key not attached to an organization
              value:
                message: API key is not bound to an organization.

    UnprocessableEntity:
      description: |
        Request validation failed, or `payload`/`context` exceeded the size limit.

        The two cases have **different body shapes**: validation failures include an `errors` object,
        size failures do not. Parse defensively.
      content:
        application/json:
          schema:
            anyOf:
              - $ref: '#/components/schemas/ValidationError'
              - $ref: '#/components/schemas/Error'
          examples:
            validation:
              summary: Missing required field
              value:
                message: The action type field is required.
                errors:
                  action_type: [The action type field is required.]
            tooLarge:
              summary: Payload or context over 256 KiB
              value:
                message: Payload or context exceeds maximum size.

    TooManyRequests:
      description: |
        **Two distinct conditions share this status.** Clients must tell them apart:

        | | Throttle | Plan cap |
        |---|---|---|
        | Meaning | Too many requests this minute | Monthly evaluation allowance exhausted |
        | `Retry-After` header | present | **absent** |
        | `evaluations_cap` in body | absent | **present** |
        | Retry? | yes, after `Retry-After` | **no** — not until the next billing month |

        Discriminate on `evaluations_cap`. Retrying a plan-cap 429 with backoff will fail for the
        remainder of the month.

        Default throttle limit is 60 requests/minute per API key.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            anyOf:
              - $ref: '#/components/schemas/QuotaExceededError'
              - $ref: '#/components/schemas/Error'
          examples:
            throttled:
              summary: Rate limited — retryable
              value:
                message: Too Many Attempts.
            quotaExhausted:
              summary: Monthly cap reached — not retryable
              value:
                message: Monthly evaluation limit reached. You have used 1000 of 1000 evaluations for this month. Upgrade to a higher plan for more.
                evaluations_used: 1000
                evaluations_cap: 1000

    OrganizationNotFound:
      description: The API key resolved to an organization id with no matching record.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              value:
                message: Organization not found.

    ServerError:
      description: |
        Server error. Rare. Because every evaluate call is a write, do **not** blindly retry — the
        evaluation may already have been recorded.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            noOrgContext:
              value:
                message: Organization context missing.
