openapi: 3.1.0
info:
  title: ActPass Gateway API
  version: "1.1.1"
  description: |
    Signed action-authorization gateway for AI agents. Every risky action gets a
    deterministic allow / deny / require_approval decision outside the LLM,
    backed by EdDSA Action Passports and recorded in a tamper-evident evidence chain.

    **Fail-closed:** errors, missing keys, and unverifiable claims always deny.
    All endpoints are tenant-scoped by the authenticated API key.
  contact:
    email: security@actpass.org
servers:
  - url: https://www.api.actpass.org/api
  - url: http://localhost:3000/api
security:
  - bearerAuth: []
tags:
  - name: Actions
    description: Preflight decisions and gated execution
  - name: Passports
    description: Issue, verify, and revoke signed Action Passports
  - name: Agents
    description: Register AI agents and mint scoped, auto-clamped Action Passports
  - name: Approvals
    description: Human-in-the-loop approval queue
  - name: Policies
    description: Deterministic policy CRUD
  - name: Tools
    description: Tool-manifest registry and drift detection
  - name: Evidence
    description: Append-only, hash-chained audit ledger
  - name: Credentials
    description: Encrypted credential vault
  - name: Keys
    description: Gateway API-key and signing-key management
  - name: Integrations
    description: Self-serve integration surfaces (ServiceNow, Slack, Teams, Vanta/Drata, GitHub, SIEM sinks)
  - name: Platform

paths:
  /v1/actions/preflight:
    post:
      tags: [Actions]
      summary: Decide whether an agent action may proceed
      description: |
        The core decision endpoint. Verifies the Action Passport (if supplied),
        checks tool-manifest drift, evaluates deterministic policy, and routes
        to human approval when required. A passport carrying a verified
        approval_hash satisfies a require_approval outcome.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PreflightRequest'
      responses:
        '200':
          description: Decision rendered (allow / require_approval / warn / deny in monitor mode)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PreflightResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/actions/execute:
    post:
      tags: [Actions]
      summary: Execute an action through the gateway
      description: |
        Runs preflight, then (on allow) forwards the call to the configured
        upstream with the bound credential injected, sealing request and result
        into the evidence chain. Requires a gateway-scope API key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PreflightRequest'
      responses:
        '200':
          description: Execution result with decision and evidence linkage
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/passports/issue:
    post:
      tags: [Passports]
      summary: Issue a signed Action Passport
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IssuePassportRequest'
      responses:
        '200':
          description: EdDSA-signed passport (JWS) and its claims
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/passports/verify:
    post:
      tags: [Passports]
      summary: Verify a passport without consuming its single-use jti
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [passport]
              properties:
                passport: { type: string, description: Compact JWS }
                audience: { type: string }
      responses:
        '200':
          description: Verification result with per-check errors and reason codes

  /v1/passports/revoke:
    post:
      tags: [Passports]
      summary: Revoke a passport by jti (durable, all replicas)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jti]
              properties:
                jti: { type: string }
                reason: { type: string }
      responses:
        '200': { description: Revoked }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/passports/jwks:
    get:
      tags: [Passports]
      summary: Public JWKS for passport verification (includes rotation-overlap keys)
      security: []
      responses:
        '200':
          description: JSON Web Key Set

  /v1/approvals:
    get:
      tags: [Approvals]
      summary: List approval requests for the tenant
      responses:
        '200': { description: Tenant-scoped approval queue }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/approvals/{id}/decide:
    post:
      tags: [Approvals]
      summary: Apply a reviewer decision (FSM-gated, RBAC approval.decide)
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [approve, deny, modify, request_context, require_reauth, require_tool_reapproval, escalate, expire]
                comment: { type: string }
                modified_args: { type: object }
      responses:
        '200':
          description: New status plus the immutable event_hash binding reviewer + action
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { description: Transition not allowed by the approval FSM }

  /v1/approvals/expire:
    post:
      tags: [Approvals]
      summary: Sweep pending approvals past the SLA window to expired
      responses:
        '200': { description: Count of expired approvals }

  /v1/policies:
    get:
      tags: [Policies]
      summary: List policies
      responses:
        '200': { description: Tenant policies }
    post:
      tags: [Policies]
      summary: Create a policy (JSON DSL, validated before persist)
      description: |
        Envelope is `{ name, rules_json }`. `rules_json` is the JSON policy DSL
        (object or JSON-encoded string) validated by the deterministic engine
        before persist — a malformed rule set is a 400 (`policy.invalid`), never
        stored. Note the DSL shape that trips people up: each rule's `when` is an
        OBJECT with exactly one of `all` / `any` (NOT a bare array), and the only
        accepted operators are `==, !=, >, >=, <, <=, in, not_in, contains,
        matches` (`eq`/`=` are rejected). See PolicyDefinition + Condition.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyCreateRequest' }
      responses:
        '201': { description: Created policy with computed policy_hash }
        '400': { description: 'Malformed rule set — reason_code policy.invalid; the message names the exact offending path and, for enums, the allowed values' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/policies/replay:
    post:
      tags: [Policies]
      summary: Test a policy against a sample action (deterministic, no persistence)
      description: |
        Runs the SAME engine used at runtime over a policy + context you supply,
        and returns the verdict WITH per-condition results (`verdict.evidence[]`)
        so you can see exactly why a rule did or didn't match. Use this to debug
        the common "my `==` rule validated but never matched" case — it is almost
        always a path mismatch (e.g. the tool name lives at `tool.name`, so a rule
        with `path: "tool"` compares an object to a string and is always false).
        See PolicyContext for the exact paths the engine reads.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyReplayRequest' }
      responses:
        '200': { description: 'Replay artifact { verdict, policy_hash, evidence_hash, conformance? } — verdict.evidence[] carries condition_results' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /v1/policies/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Policies]
      summary: Fetch a policy
      responses:
        '200': { description: Policy document }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Policies]
      summary: Update a policy (bumps version, recomputes hash)
      responses:
        '200': { description: Updated policy }
    delete:
      tags: [Policies]
      summary: Delete a policy
      responses:
        '200': { description: Deleted }

  /v1/tools/ingest:
    post:
      tags: [Tools]
      summary: Register or update a tool manifest (canonicalized + hashed)
      description: |
        Envelope is `{ manifest: {...} }` describing ONE tool. The only hard
        requirement is a top-level `manifest.name`; all other fields default if
        omitted. A bundle of multiple tools under one manifest is accepted but
        registers only the single top-level `name` — POST once per tool to
        register several. Drift from the previously approved manifest is
        classified; material changes flip the tool to require re-approval at runtime.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ToolIngestRequest' }
      responses:
        '200': { description: 'Manifest hash, normalized manifest, approval_status, and drift classification' }
        '400': { description: 'Missing/invalid manifest — reason_code args.schema_invalid' }

  /v1/agents/register:
    post:
      tags: [Agents]
      summary: Register (or idempotently upsert) an AI agent
      description: |
        Upsert on `(name, kind)` within the tenant. `capabilities` is the upper
        bound on tools any passport for this agent can carry — issue-passport
        clamps requested tools to this set. Tenant comes from the API key, never
        the body.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentRegisterRequest' }
      responses:
        '201': { description: 'Registered agent { agent_id, ... }' }
        '400': { description: 'reason_code args.schema_invalid (name/capabilities required)' }
        '402': { description: 'Plan agent cap reached' }

  /v1/agents/issue-passport:
    post:
      tags: [Agents]
      summary: Mint a short-lived scoped Action Passport for a registered agent
      description: |
        Requested tools are clamped to the agent's declared capabilities and the
        team's active policy hash is stamped in. Call /v1/agents/register first.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueAgentPassportRequest' }
      responses:
        '201': { description: 'Issued passport { token, claims, expires_at, evidence_id }' }
        '400': { description: 'reason_code args.schema_invalid (agent_id/requested_tools required)' }
        '403': { description: 'reason_code passport.tool_not_allowed — no requested tool intersects capabilities' }
        '404': { description: 'reason_code agent.not_found' }
        '500': { description: 'Server-side failure; reason_code names the failing step (policy.load_failed, passport.sign_failed, passport.persist_failed, evidence.seal_failed, agent.load_failed, or internal.unexpected)' }

  /v1/tools/diff:
    post:
      tags: [Tools]
      summary: Classify drift between two tool manifests
      responses:
        '200': { description: Drift classes with reason codes (e.g. tool.read_to_write_conversion) }

  /v1/evidence/events:
    get:
      tags: [Evidence]
      summary: List evidence events (tenant-scoped, cursor-paginated)
      parameters:
        - { name: chain_id, in: query, schema: { type: string } }
        - { name: agent_id, in: query, schema: { type: string } }
        - { name: tool, in: query, schema: { type: string } }
        - { name: decision, in: query, schema: { type: string } }
        - { name: reason_code, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque cursor from a previous page's next_cursor }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        '200': { description: 'Hash-chained evidence events { events[], next_cursor }' }
        '400': { description: Malformed filter, bound, or cursor (fail-closed; filters are never silently ignored) }
    post:
      tags: [Evidence]
      summary: Append a custom evidence event to the chain
      responses:
        '200': { description: Sealed event with chain linkage }

  /v1/evidence/stream:
    get:
      tags: [Evidence]
      summary: Live evidence tail (Server-Sent Events)
      description: >-
        text/event-stream of new evidence rows. Accepts the same filter grammar
        as GET /v1/evidence/events. Each message's SSE `id:` is that row's
        opaque cursor; reconnect with the standard Last-Event-ID header (or a
        `cursor` query param) to resume without skips or duplicates. Without a
        resume position the stream starts at the tenant's current tip.
      parameters:
        - { name: chain_id, in: query, schema: { type: string } }
        - { name: agent_id, in: query, schema: { type: string } }
        - { name: tool, in: query, schema: { type: string } }
        - { name: decision, in: query, schema: { type: string } }
        - { name: reason_code, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: cursor, in: query, schema: { type: string }, description: Resume strictly after this position }
        - { name: Last-Event-ID, in: header, schema: { type: string }, description: Standard EventSource reconnect cursor }
      responses:
        '200': { description: 'SSE stream; `event: evidence` messages with the sealed row as JSON data' }
        '400': { description: Malformed filter, bound, or cursor }

  /v1/evidence/stats:
    get:
      tags: [Evidence]
      summary: Aggregated evidence stats (decision totals, deny reasons, approval latency percentiles)
      description: Deterministic SQL aggregation over the tenant's ledger — no model output in this surface.
      parameters:
        - { name: group_by, in: query, schema: { type: string, enum: [day, agent, tool], default: day } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        '200': { description: '{ group_by, totals[], decision_counts[], top_deny_reasons[], approval_latency_ms }' }
        '400': { description: Unknown group_by or malformed bound }

  /v1/evidence/verify:
    get:
      tags: [Evidence]
      summary: Recompute and verify the evidence hash chain
      description: >-
        Recomputes hashes from sealed canonical events — content tampering and
        chain breaks are both detected. A failed verification also records a
        chain_verification_failed incident event and notifies configured
        alerting (best-effort; the verification report is returned regardless).
      responses:
        '200': { description: Chain verification report }

  /v1/evidence/export:
    get:
      tags: [Evidence]
      summary: Export an evidence bundle (JSON / Markdown / CSV / SIEM-JSONL), Ed25519-signed
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [json, md, csv, siem] } }
      responses:
        '200': { description: Signed evidence bundle }

  /v1/credentials:
    get:
      tags: [Credentials]
      summary: List vault items (metadata only — secrets never leave the vault)
      responses:
        '200': { description: Credential metadata }
    post:
      tags: [Credentials]
      summary: Store a credential (AES-256-GCM envelope encryption)
      responses:
        '200': { description: Stored; secret is write-only }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/keys:
    get:
      tags: [Keys]
      summary: List API keys (hashes only)
      responses:
        '200': { description: Key metadata }
    post:
      tags: [Keys]
      summary: Create an API key (returned once, stored hashed)
      responses:
        '200': { description: The new key — shown exactly once }
        '403': { $ref: '#/components/responses/Forbidden' }
    delete:
      tags: [Keys]
      summary: Revoke an API key
      responses:
        '200': { description: Revoked }

  /v1/keys/signing/rotate:
    post:
      tags: [Keys]
      summary: Rotate the team's report/attestation signing key (W0-6)
      description: |
        Mints a new active ES256 signing key and RETIRES the previous one.
        Retired keys keep verifying historical artifacts (nothing is ever
        deleted) and `/v1/evidence/jwks` immediately serves the updated set.
        The rotation itself is sealed into the evidence ledger so key lifecycle
        is part of the tamper-evident record. Requires `key.write`.
      responses:
        '200': { description: 'New active key id + retired key id; rotation sealed into evidence' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/compliance:
    get:
      tags: [Platform]
      summary: Evaluate compliance controls (CC-1…CC-6) against live system state
      responses:
        '200': { description: Control-by-control pass/fail with evidence }

  /v1/integrations/siem:
    get:
      tags: [Integrations]
      summary: SIEM-formatted event stream (Splunk / Datadog / CEF / webhook)
      responses:
        '200': { description: Exported events in the requested SIEM dialect }

  /v1/integrations/sinks:
    get:
      tags: [Integrations]
      summary: List SIEM push sinks + delivery health (secrets never returned)
      responses:
        '200': { description: 'Tenant sinks with per-sink delivery health' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Integrations]
      summary: Register a SIEM push sink (splunk_hec | datadog | webhook | syslog_cef)
      description: Requires `settings.write`. The sink secret is write-only.
      responses:
        '200': { description: Sink registered }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/integrations/sinks/{id}:
    patch:
      tags: [Integrations]
      summary: 'Update a sink (endpoint / secret / active); `secret: null` clears it'
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Updated }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Integrations]
      summary: Remove a sink
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Removed }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/integrations/sinks/drain:
    post:
      tags: [Integrations]
      summary: Push the evidence stream to every active sink once (cron-callable)
      description: >-
        Returns a per-sink delivery report. Requires `settings.write`; a
        gateway/tenant service key can drive it from a scheduler.
      responses:
        '200': { description: Per-sink delivery report }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/integrations/deliveries:
    get:
      tags: [Integrations]
      summary: 'Outbound delivery queue: pending / delivered / dead-lettered rows + health counts'
      description: >-
        Read-only view of the outbound notification queue (payloads elided).
        Dead-lettered rows are retained for inspection and replay; secrets and
        credentials never appear in delivery rows.
      responses:
        '200': { description: 'Queue rows + per-channel health counts' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/deliveries/drain:
    post:
      tags: [Integrations]
      summary: Drain the outbound queue once with retry/backoff (cron-callable)
      description: >-
        Attempts every due pending delivery; failures reschedule with
        exponential backoff and dead-letter after the attempt cap. Requires
        `settings.write`; a gateway/tenant service key can drive it from a
        scheduler. Also accepts replay of a dead-lettered row via `replay_id`.
      responses:
        '200': { description: 'Drain report: attempted / delivered / rescheduled / dead-lettered' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/integrations/servicenow/status:
    get:
      tags: [Integrations]
      summary: 'ServiceNow integration health: typed checklist + exact next step'
      description: >-
        Self-serve W0-5 status contract — read-only, metadata-only checks
        (install, instance_bound, push_credential, webhook_loop); config and
        credentials never leave the store layer.
      responses:
        '200':
          description: Typed checklist; `next_step` is the first failing check's remediation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IntegrationStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/slack/status:
    get:
      tags: [Integrations]
      summary: 'Slack integration health: typed checklist + exact next step'
      responses:
        '200':
          description: Typed checklist (same W0-5 contract as every /status route)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IntegrationStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/teams/status:
    get:
      tags: [Integrations]
      summary: 'Microsoft Teams integration health: typed checklist + exact next step'
      responses:
        '200':
          description: Typed checklist (same W0-5 contract as every /status route)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IntegrationStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/compliance-export/status:
    get:
      tags: [Integrations]
      summary: 'Vanta / Drata export health: typed checklist + exact next step'
      parameters:
        - name: vendor
          in: query
          required: false
          schema: { type: string, enum: [vanta, drata], default: vanta }
      responses:
        '200':
          description: Typed checklist (same W0-5 contract as every /status route)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IntegrationStatus'
        '400': { description: 'vendor must be "vanta" or "drata"' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/compliance-export:
    post:
      tags: [Integrations]
      summary: Managed Vanta/Drata upload proxy — the server holds the vendor token
      description: >-
        The CLI sends the compliance export bundle built by buildExportBundle;
        the server holds the vendor token so it never lands on a customer
        machine. Tenant comes from the authenticated principal, never the
        bundle body.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: Compliance export bundle (buildExportBundle output) }
      responses:
        '200': { description: Upload result }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/installs:
    get:
      tags: [Integrations]
      summary: List this tenant's managed-integration installs (metadata only)
      description: Config/credentials never leave the store layer.
      responses:
        '200': { description: Install records (metadata only) }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Integrations]
      summary: Manually bind a channel that has no OAuth flow
      description: >-
        teams → { gatewayKey } + external_id (Azure AD tenant id);
        vanta/drata → { token, uploadUrl }; servicenow → external_id (instance
        name) + optional { auth } push credential. Slack is NOT accepted here —
        it installs via the OAuth redirect route. Stored encrypted
        (AES-256-GCM, AAD tenantId:channel).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel]
              properties:
                channel: { type: string, enum: [teams, vanta, drata, servicenow] }
                external_id: { type: string }
                config: { type: object, description: Channel-specific binding (never returned) }
      responses:
        '200': { description: Install registered (metadata only) }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/servicenow/webhook:
    post:
      tags: [Integrations]
      summary: Inbound ServiceNow approval state change (Update Set business rule)
      description: >-
        The customer's Update Set business rule POSTs { approval_id, state }
        with their gateway-scope key. Tenant comes from the authenticated
        principal, never the body; the SNOW state is mapped and re-dispatched
        to the decide FSM route with source:'servicenow'.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [approval_id, state]
              properties:
                approval_id: { type: integer }
                state: { type: string, description: ServiceNow approval state (mapped to approve/deny) }
      responses:
        '200': { description: Decision applied (or status already_decided) }
        '400': { description: Invalid approval_id or unmappable state }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Gateway-scope key required }
        '404': { description: No servicenow install for this tenant }

  /v1/integrations/slack/install:
    get:
      tags: [Integrations]
      summary: Start the ActPass Slack OAuth flow
      description: >-
        Tenant comes from the authenticated principal, is HMAC-signed into the
        OAuth `state`, and round-trips back in oauth_redirect.
      responses:
        '302': { description: Redirect to the Slack authorize URL }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { description: Slack app not configured }

  /v1/integrations/slack/oauth_redirect:
    get:
      tags: [Integrations]
      summary: Finish the Slack install (OAuth callback)
      security: []
      description: >-
        Verifies the signed `state`, exchanges the OAuth `code` for a bot token
        (client secret never leaves the server), then persists the install
        encrypted, keyed by Slack team_id. Auth is the HMAC-signed state — not
        a bearer key (the browser is redirected here by Slack).
      parameters:
        - { name: code, in: query, required: true, schema: { type: string } }
        - { name: state, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Install completed }
        '400': { description: Missing code or invalid state }
        '402': { description: Plan does not include this integration }
        '503': { description: Slack app not configured }

  /v1/integrations/slack/interactions:
    post:
      tags: [Integrations]
      summary: Slack Approve/Deny button clicks (signature-verified)
      security: []
      description: >-
        Slack interactivity endpoint. The Slack HMAC is verified over the raw
        bytes with a 5-minute replay window BEFORE parsing; the tenant is
        resolved from the signature-verified team_id via the install store —
        never from the interaction payload. The inbound decide call uses a
        server-held gateway key; source:'slack' tags the audit row.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                payload: { type: string, description: Slack interaction payload (JSON-encoded) }
      responses:
        '200': { description: Decision applied (or ok:true with ignored reason) }
        '400': { description: Malformed payload or missing team/reviewer identity }
        '401': { description: Bad Slack signature }
        '403': { description: Workspace not installed or gateway key missing }
        '503': { description: Slack app not configured }

  /v1/integrations/teams/messages:
    post:
      tags: [Integrations]
      summary: ActPass-owned Teams bot endpoint (Bot Framework)
      security: []
      description: >-
        Microsoft's Bot Connector POSTs a signed Activity when a reviewer taps
        Approve/Deny on an ActPass Adaptive Card. The Bot Framework JWT is
        verified; the tenant is resolved from the install record keyed by the
        Azure tenant id — never from the body — and the Action.Submit is mapped
        to a decide request with source:'teams'.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: Bot Framework Activity }
      responses:
        '200': { description: 'ok:true with approvalId + action (or ignored non-invoke activity)' }
        '400': { description: Unmappable activity or missing tenant }
        '401': { description: Bot Framework JWT verification failed }
        '403': { description: Teams not installed or gateway key missing }
        '502': { description: Downstream decide route failed }

  /v1/integrations/servicenow/update-set:
    get:
      tags: [Integrations]
      summary: Download the ServiceNow bridge Update Set zip (checksummed)
      description: >-
        Serves the same artifact shipped to the ServiceNow Store; contains no
        secrets. Auth-gated so download telemetry stays tied to a tenant.
        `X-Checksum-Sha256` (and `?checksum=1`) expose the sha256 sidecar.
      parameters:
        - name: checksum
          in: query
          required: false
          schema: { type: integer, enum: [1] }
          description: Return the sha256 sidecar instead of the zip
      responses:
        '200': { description: The Update Set zip (application/zip) or its sha256 }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/github/import:
    post:
      tags: [Integrations]
      summary: 'GitHub pipeline: repo import → agent-surface inference → MCP policies → push-back plan'
      description: >-
        Body: `{ repo: "owner/name", branch?, files? }`. Until a GitHub App
        token is configured the stub credential provider reads the supplied
        `files` snapshot and returns the push-back plan without writing to
        GitHub.
      responses:
        '200': { description: Inferred agent surface, generated policies, and the push-back plan }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/integrations/servicenow/acl-sync:
    post:
      tags: [Integrations]
      summary: Sync ServiceNow ACLs into a compiled database-access policy pack
      description: |
        Pull / zero-credential posture: the customer's Update Set scheduled job
        POSTs `sys_security_acl` (+ role) definitions with the gateway key
        already stored in the instance. ActPass deterministically compiles them
        into the generated pack "ServiceNow ACLs — <instance>" with per-rule
        provenance (`sys_id`, `sys_updated_on`, content hash).

        **Fail-closed mapping:** script-conditioned or otherwise untranslatable
        ACLs become `require_approval`, never `allow`; field ACLs on reads are
        reported unmappable (response-plane, not faked). **Drift = re-consent:**
        a changed compiled-pack hash flips the affected allow rules to
        `require_approval` until re-consent (`reconsent: true` plus
        `expected_pack_hash` matching the reviewed projection). Every sync seals
        an Ed25519-signed compilation report into the evidence chain
        `servicenow-acl-sync:<instance>`.

        Requires a gateway-scope key and a completed ServiceNow install.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ServiceNowAclSyncRequest'
      responses:
        '200':
          description: >-
            Sync outcome (unchanged / pending_reconsent / drift_pending_reconsent /
            reconsented) with the signed compilation report and evidence seal.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServiceNowAclSyncResponse'
        '201':
          description: First sync — projection pack installed (as a draft policy).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServiceNowAclSyncResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { description: ServiceNow integration not installed for this tenant }
        '402': { description: Plan policy limit reached (new projection row refused) }
        '409': { description: Instance mismatch, or reconsent hash does not match the compiled projection }
        '503': { description: Report signing unavailable — nothing was stored (fail-closed) }

  /v1/certified/{id}:
    get:
      tags: [Platform]
      summary: Public certification verify payload (no auth)
      description: |
        Returns the exact canonical certification record, its detached Ed25519
        signature, and the published public JWK, so a third party can verify
        offline — no shared secret ever leaves ActPass. Resolves ONLY by the
        unguessable certification id (never an enumerable serial); 404 fails closed.
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: '{ record, signature, public_jwk } — verify record+signature with the JWK'
        '404': { $ref: '#/components/responses/NotFound' }

  /exposure/scan:
    post:
      tags: [Platform]
      summary: Public Lethal-Trifecta exposure scan for a pasted mcp.json (no auth)
      description: |
        Zero-friction front door — paste an mcp.json, get the blast-radius verdict.
        Read-only classifier (no DB writes, no credential access), same engine as the
        authed dashboard scanner and the `actpass exposure` CLI. Accepts a bare
        mcp.json or `{ configJson: {...} }`. Abuse-bounded: 20 scans/min/IP,
        256 KB body cap, 500-server cap.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mcpServers: { type: object, additionalProperties: true }
      responses:
        '200': { description: '{ report, remediation_markdown }' }
        '400': { description: Invalid JSON or no mcpServers object }
        '413': { description: Body over 256 KB or more than 500 servers }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Gateway API key (create via POST /v1/keys). Dev fallback keys via ACTPASS_API_KEYS.

  responses:
    Unauthorized:
      description: Missing/invalid API key or passport
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    Forbidden:
      description: Authenticated but not permitted (RBAC or policy deny)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    NotFound:
      description: Resource not found in this tenant
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    BadRequest:
      description: Malformed request body or parameters
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    RateLimited:
      description: Tenant request budget exceeded (fixed window per minute)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }

  schemas:
    ApiError:
      type: object
      required: [error]
      properties:
        error: { type: string, description: Human-readable message }
        reason_code: { type: string, description: Typed reason code (e.g. passport.replay_detected, approval.invalid) }

    IntegrationStatusCheck:
      type: object
      required: [id, ok, detail]
      properties:
        id: { type: string, description: Stable check identifier (e.g. install, instance_bound, push_credential, webhook_loop) }
        ok: { type: boolean }
        detail: { type: string, description: Human-readable result for this check }
        next_step: { type: string, description: Concrete remediation — present iff `ok` is false }

    IntegrationStatus:
      type: object
      description: >-
        Shared W0-5 self-serve status contract (lib/actpass/integration-status.ts) —
        every integration /status route returns this shape. Checks are read-only and
        metadata-only; config/credentials never leave the store layer. Responses pass
        through dualCase() so every snake_case field also carries a camelCase alias
        (next_step → nextStep).
      required: [channel, ok, healthy, checks]
      properties:
        channel: { type: string, description: Which integration was checked (servicenow, slack, teams, vanta, drata) }
        ok: { type: boolean, description: True iff every check passed }
        healthy: { type: boolean, description: Back-compat alias of `ok` (pre-W0-5 ServiceNow consumers + dashboard card) }
        checks:
          type: array
          items: { $ref: '#/components/schemas/IntegrationStatusCheck' }
        next_step: { type: string, description: First failing check's remediation — the one concrete thing to do next }
        docs: { type: string, description: Docs path for this integration }

    PreflightRequest:
      type: object
      required: [tool, resource, agent_id, user_id]
      properties:
        tool: { type: string, example: stripe.refund.create }
        resource: { type: string, example: stripe:charge:ch_123 }
        args: { type: object, additionalProperties: true }
        agent_id: { type: string }
        agent_name: { type: string }
        user_id: { type: string }
        goal: { type: string }
        mode: { type: string, enum: [monitor, warn, enforce, strict] }
        passport: { type: string, description: Compact JWS Action Passport }
        audience: { type: string }
        idempotency_key: { type: string }

    PreflightResponse:
      type: object
      properties:
        decision: { type: string, enum: [allow, deny, warn, require_approval, require_tool_reapproval] }
        reason_code: { type: string, example: approval.satisfied }
        summary: { type: string }
        matched_rules: { type: array, items: { type: string } }
        approval_request_id: { type: string }
        chain_id: { type: string, description: Evidence-chain id linking this decision to its audit trail }

    IssuePassportRequest:
      type: object
      required: [tenant_id, agent_id, user_id, goal, allowed_tools, risk_tier, policy_id, audience, ttl_seconds]
      properties:
        tenant_id: { type: string }
        agent_id: { type: string }
        user_id: { type: string }
        goal: { type: string }
        allowed_tools: { type: array, items: { type: string } }
        allowed_resources: { type: array, items: { type: string } }
        resource_constraints: { type: object, additionalProperties: true }
        risk_tier: { type: string, enum: [low, medium, high, critical] }
        policy_id: { type: string }
        approval_hash: { type: [string, "null"], description: event_hash of a granted approval — verified fail-closed at use time }
        audience: { type: string }
        ttl_seconds: { type: integer }

    # ---- Policy DSL (POST /v1/policies, POST /v1/policies/replay) --------------
    PolicyCreateRequest:
      type: object
      required: [name, rules_json]
      properties:
        name: { type: string, maxLength: 100, example: outbound-email-safety }
        rules_json:
          description: The policy DSL — a PolicyDefinition object (or a JSON-encoded string of one).
          $ref: '#/components/schemas/PolicyDefinition'

    PolicyDefinition:
      type: object
      required: [id, version, rules]
      properties:
        id: { type: string, example: outbound-email-safety }
        version: { type: number, example: 1 }
        description: { type: string }
        mode: { type: string, enum: [monitor, warn, enforce, strict], description: Default enforce }
        applies_to:
          type: object
          description: Optional scoping; if set, the policy only applies when the context tool/agent is in these lists.
          properties:
            tools: { type: array, items: { type: string } }
            agents: { type: array, items: { type: string } }
        rules:
          type: array
          items: { $ref: '#/components/schemas/PolicyRule' }
      example:
        id: outbound-email-safety
        version: 1
        mode: enforce
        applies_to: { tools: [gws.gmail.send, gws.gmail.reply, gws.gmail.forward] }
        rules:
          - name: block-secrets
            decision: deny
            reason: dlp.secret_detected
            when: { all: [ { path: args.contains_secret, operator: '==', value: true } ] }
          - name: approve-ambiguous-external
            decision: require_approval
            reason: dlp.external_ambiguous
            approval: { channel: slack, min_role: reviewer }
            when: { all: [ { path: args.recipient_external, operator: '==', value: true }, { path: args.risk_ambiguous, operator: '==', value: true } ] }
          - name: allow-clean
            decision: allow
            reason: dlp.clean
            when: { any: [ { path: args.contains_secret, operator: '==', value: false } ] }

    PolicyRule:
      type: object
      required: [name, decision, reason, when]
      properties:
        name: { type: string }
        decision:
          type: string
          enum: [allow, deny, warn, require_approval, require_reauth, require_tool_reapproval, require_policy_update, dry_run]
        reason: { type: string, description: Free-form reason_code stamped into the verdict + evidence }
        approval:
          type: object
          properties:
            channel: { type: string }
            min_role: { type: string }
        when:
          type: object
          description: A condition GROUP — exactly one of all/any (NOT a bare array). all = every condition true; any = at least one.
          properties:
            all: { type: array, items: { $ref: '#/components/schemas/PolicyCondition' } }
            any: { type: array, items: { $ref: '#/components/schemas/PolicyCondition' } }

    PolicyCondition:
      type: object
      required: [path, operator, value]
      properties:
        path: { type: string, description: 'Dotted path into PolicyContext (e.g. args.amount, tool.name, passport.resource_constraints.max_amount).', example: args.amount }
        operator:
          type: string
          enum: ['==', '!=', '>', '>=', '<', '<=', in, not_in, contains, matches]
          description: '`==`/`!=` are STRICT (type-sensitive) equality; ordered ops coerce numeric strings. `eq` and `=` are NOT accepted.'
        value:
          description: 'A literal, OR a path reference { "$ref": "<dotted-path>" } to compare two context values.'
          oneOf:
            - {}
            - type: object
              properties:
                $ref: { type: string }

    PolicyContext:
      type: object
      description: |
        The exact shape the engine evaluates `path` against. Use POST
        /v1/policies/replay to see the resolved values. Common gotcha: the tool
        NAME is `tool.name` (not `tool` or `tool_name`); call args are under `args`.
      properties:
        tenant: { type: object, properties: { id: { type: string }, name: { type: string } } }
        agent: { type: object, properties: { id: { type: string }, name: { type: string }, goal: { type: string } } }
        user: { type: object, properties: { id: { type: string }, email: { type: string }, role: { type: string } } }
        tool: { type: object, properties: { name: { type: string }, current_manifest_hash: { type: string }, risk_tier: { type: string } } }
        passport:
          type: object
          properties:
            goal: { type: string }
            resource_constraints: { type: object, additionalProperties: true }
            audience: { type: string }
            tenant_id: { type: string }
        args: { type: object, additionalProperties: true, description: The canonicalized call arguments }
        resource: { type: object, properties: { id: { type: string }, type: { type: string }, customer_id: { type: string } } }
        environment: { type: string, enum: [dev, staging, prod] }

    PolicyReplayRequest:
      type: object
      required: [policy, context]
      properties:
        policy: { $ref: '#/components/schemas/PolicyDefinition' }
        context: { $ref: '#/components/schemas/PolicyContext' }
        expected:
          type: object
          description: Optional — when supplied, the response includes a conformance pass/fail.
          properties:
            decision: { type: string }
            reason_code: { type: string }

    # ---- Tool manifest (POST /v1/tools/ingest) --------------------------------
    ToolIngestRequest:
      type: object
      required: [manifest]
      properties:
        manifest: { $ref: '#/components/schemas/ToolManifest' }

    ToolManifest:
      type: object
      required: [name]
      description: Only `name` is required; every other field defaults if omitted.
      properties:
        name: { type: string, example: gws.gmail.send }
        description: { type: string }
        serverOrigin: { type: string, example: mcp://google-workspace }
        protocol: { type: string, example: mcp }
        publisherIdentity: { type: string }
        publisherVerified: { type: boolean }
        inputSchema: { type: object, additionalProperties: true }
        outputSchema: { type: object, additionalProperties: true }
        oauthScopes: { type: array, items: { type: string } }
        sideEffectProfile: { type: array, items: { type: string }, example: [network.send, data.external] }
        riskTier: { type: string, enum: [low, medium, high, critical] }
        executionCapability: { type: string }
        sensitiveDataExposure: { type: string }

    # ---- Agent lifecycle (POST /v1/agents/*) ----------------------------------
    AgentRegisterRequest:
      type: object
      required: [name, capabilities]
      properties:
        name: { type: string, maxLength: 100, example: gmail-content-safety-agent }
        capabilities:
          type: array
          items: { type: string }
          description: Upper bound on tools any issued passport may carry.
          example: [gws.gmail.send, gws.gmail.reply, gws.gmail.forward]
        kind: { type: string, maxLength: 30, default: ai_developer }
        goal: { type: string }
        blastRadius: { type: string, enum: [low, medium, high, critical] }

    IssueAgentPassportRequest:
      type: object
      required: [agent_id, requested_tools]
      properties:
        agent_id: { type: integer, description: The numeric id returned by /v1/agents/register }
        requested_tools:
          type: array
          items: { type: string }
          description: Clamped to the agent's registered capabilities.
          example: [gws.gmail.send]
        requested_resources: { type: array, items: { type: string } }
        resource_constraints: { type: object, additionalProperties: true }
        ttl_seconds: { type: integer, example: 900 }
        purpose: { type: string }
        audience: { type: string, default: 'actpass://agents' }
        risk_tier: { type: string, enum: [low, medium, high, critical] }

    # ---- ServiceNow ACL sync (POST /v1/integrations/servicenow/acl-sync) ------
    ServiceNowAcl:
      type: object
      required: [sys_id, name, operation]
      properties:
        sys_id: { type: string, maxLength: 64, description: ServiceNow sys_security_acl sys_id (provenance key) }
        sys_updated_on: { type: string, maxLength: 64 }
        name: { type: string, maxLength: 200, description: Protected table (or table.field) name, e.g. incident }
        operation: { type: string, maxLength: 32, example: read }
        active: { type: boolean }
        roles: { type: array, maxItems: 50, items: { type: string, maxLength: 120 } }
        condition: { type: string, maxLength: 4000, description: Declarative condition — compiled where deterministically translatable }
        script: { type: string, maxLength: 20000, description: Script body. Never executed or translated — its presence forces require_approval (fail-closed). }
        description: { type: string, maxLength: 1000 }

    ServiceNowAclSyncRequest:
      type: object
      required: [connection, acls]
      properties:
        instance: { type: string, maxLength: 200, example: acme.service-now.com, description: Must match the installed integration's instance (409 on mismatch) }
        connection: { type: string, maxLength: 120, description: Named DB connection the compiled pack scopes to }
        acls:
          type: array
          minItems: 1
          maxItems: 2000
          items: { $ref: '#/components/schemas/ServiceNowAcl' }
        reconsent: { type: boolean, description: Human consent to the current projection; required to lift pending_reconsent flips }
        expected_pack_hash: { type: string, maxLength: 80, description: With reconsent — the reviewed pack hash; 409 if it differs from the compiled projection }

    ServiceNowAclSyncResponse:
      type: object
      required: [status, pack_id, pack_version, pack_hash, report, report_signature, evidence]
      properties:
        status:
          type: string
          enum: [installed, unchanged, reconsented, pending_reconsent, drift_pending_reconsent]
        pack_id: { type: string }
        pack_version: { type: integer }
        pack_hash: { type: string, description: Deterministic hash of the compiled projection — the re-consent target }
        previous_pack_hash: { type: string, nullable: true }
        drift:
          type: object
          nullable: true
          description: Present on drift_pending_reconsent — what changed and which allow rules were flipped to require_approval.
          properties:
            added: { type: array, items: { type: string } }
            changed: { type: array, items: { type: string } }
            removed: { type: array, items: { type: string } }
            affected_tables: { type: array, items: { type: string } }
            flipped: { type: array, items: { type: string } }
        report:
          type: object
          additionalProperties: true
          description: Signed compilation report (counts + per-rule provenance), also sealed into the evidence chain.
        report_signature: { type: object, additionalProperties: true, description: Detached Ed25519 signature over the report }
        evidence:
          type: object
          properties:
            chain_id: { type: string, example: 'servicenow-acl-sync:acme.service-now.com' }
            sealed: { type: boolean }
            event_hash: { type: string, nullable: true }
            dead_lettered: { type: boolean, description: True if sealing hit a transient failure and the event was dead-lettered instead of lost }
