# =============================================================================
# XRAY Platform API — canonical OpenAPI 3.1 contract
# =============================================================================
# THIS FILE IS THE SOURCE OF TRUTH for the platform's integration surface:
# paths, methods, auth planes, and wire shapes across the workspace and every
# microservice. docs/MICROSERVICE-API-SPEC.md remains normative for prose
# algorithms (HMAC construction, retry semantics, trust tiers); where the two
# disagree on a path/method/shape, THIS file wins and the drift is a bug.
#
# Editing rules:
#   * Hand-edit ONLY this YAML. `npm run openapi:build` regenerates the
#     checked-in serving module (src/lib/generated/platform-openapi.ts);
#     `npm run openapi:check` (in the validate chain) fails on any drift
#     between this file, the generated module, and the actual route files
#     across the workspace + sibling service repos.
#   * Scope: the INTEGRATION surface only. The workspace's ~200
#     session-cookie BFF routes (/api/workspace/**) are internal and are
#     deliberately absent.
#   * Path keys are unique per OpenAPI — the uniform per-service v1 contract
#     (health/ready/manifest/...) is therefore documented ONCE under the
#     `service-contract` tag, with `x-service` naming every service that
#     implements it and a path-level `servers` array listing each base URL.
#     Standard tooling (Swagger UI, Postman) honors path-level servers;
#     openapi-generator falls back to the global server — acceptable.
#   * Depth rule: request bodies of mutating operations and the shared
#     platform schemas are fully modeled; long-tail response payloads are
#     `type: object` with a description (the envelope and status codes are
#     always exact).
#   * `x-service` maps each path to its owning repo(s) for conformance:
#     workspace | blob | guardian | decide | dictate | forloco.
# =============================================================================

openapi: 3.1.0

info:
  title: XRAY Platform API
  version: "1.0.0" # keep in step with contractVersion v1
  description: |
    The complete integration surface of the XRAY platform: the endpoints a
    microservice calls on the **XR Workspace** (signed events, subject
    context, service data plane), the **uniform service contract** every
    first-party microservice implements, and each service's own machine API
    (**Blob**, **Guardian**, **Decide**, **Dictate**, **ForLoCo**).

    ### Envelope
    Success responses are `{ "data": ... }`; failures are
    `{ "error": { "code", "message", "details?" } }` (see `ErrorEnvelope`
    and the `x-error-codes` taxonomy). A handful of legacy Blob routes still
    return `{ "error": "message" }` — marked `x-legacy-envelope: true`.

    ### Auth planes
    * **AUTH-1** — static per-service transport key header
      (`X-Blob-Api-Key`, `X-Guardian-Api-Key`, `X-Decide-Api-Key`,
      `x-dictate-api-key`). Fail-closed: production answers 503 when unset.
    * **AUTH-2** — HMAC event signature on the workspace event bus.
    * **AUTH-4** — `X-Workspace-Token`: HS256 JWT minted by the workspace
      (iss `workspace.xray.tech`, aud = the service id, exp−iat ≤ 300s).
    * **AUTH-5** — cron secrets for scheduled internal jobs.
    * **AUTH-6** — HMAC context signature for workspace pull APIs.
    Additional planes: Decide caller keys (`Bearer dcd_…`), Guardian public
    consumer keys (`Bearer xrg_live_…`), Guardian admin allowlist,
    ForLoCo's internal shared key, provider-signed webhooks.

    Download: JSON at `/api/v1/openapi`, YAML at `/api/v1/openapi?format=yaml`.
  contact:
    name: XRAY Platform
    url: https://workspace.xray.tech/api

servers:
  - url: https://workspace.xray.tech
    description: XR Workspace (hub) — every path without a servers override

tags:
  - name: workspace
    description: Service-facing endpoints the workspace exposes to microservices.
  - name: service-contract
    description: >-
      The uniform /api/v1 plane every first-party microservice implements
      (health, ready, manifest, version, summary, activity, search, facts,
      pages, actions). Documented once; `x-service` lists the implementers
      and the path-level `servers` array carries each base URL. Dictate does
      not implement this plane yet.
  - name: blob
    description: Blob — data-source schema indexing & insights (aud `blob`).
  - name: guardian
    description: Guardian — error & automation monitoring (aud `guardian`).
  - name: guardian-admin
    description: Guardian consumer-key administration (admin email allowlist).
  - name: guardian-public
    description: Guardian public read-only consumer API (Bearer xrg_live_…; default OFF).
  - name: decide-caller
    description: Decide caller plane — external services request human decisions (Bearer dcd_…).
  - name: decide-workspace
    description: Decide workspace plane — inbox, decisions, roles, keys (AUTH-1 + AUTH-4).
  - name: dictate
    description: Dictate — voice sessions → extracted actions (AUTH-1 + user-email header).
  - name: forloco
    description: ForLoCo (Hourly) machine surface — embed SSO mint, roster, provider webhooks.
  - name: internal-cron
    description: Scheduled internal jobs (AUTH-1/AUTH-5) — not for integrators.

# The platform-wide error-code taxonomy (rendered by the docs explorer; the
# codes appear in ErrorEnvelope.error.code across every service).
x-error-codes:
  - code: VALIDATION_ERROR
    http: 400
    meaning: Body or query failed validation
  - code: INVALID_JSON
    http: 400
    meaning: Request body isn't valid JSON
  - code: UNKNOWN_EVENT_TYPE
    http: 400
    meaning: Event type isn't in the workspace catalog
  - code: UNAUTHORIZED
    http: 401
    meaning: Missing/invalid credential
  - code: FORBIDDEN
    http: 403
    meaning: Authenticated but not entitled
  - code: NOT_FOUND
    http: 404
    meaning: Missing or not owned — never reveal which
  - code: RATE_LIMITED
    http: 429
    meaning: Throttled; retry with backoff
  - code: INTERNAL_ERROR
    http: 500
    meaning: Unexpected; message stays user-safe
  - code: NOT_CONFIGURED
    http: 501
    meaning: Service env not set — degrade, don't crash (permanent, never retried)
  - code: UPSTREAM_ERROR
    http: 502
    meaning: A dependency is down

components:
  securitySchemes:
    blobApiKey:
      type: apiKey
      in: header
      name: X-Blob-Api-Key
      description: "AUTH-1: Blob's static transport key. Fail-closed (503 in prod when unset)."
    guardianApiKey:
      type: apiKey
      in: header
      name: X-Guardian-Api-Key
      description: "AUTH-1: Guardian's static transport key. Fail-closed."
    decideApiKey:
      type: apiKey
      in: header
      name: X-Decide-Api-Key
      description: "AUTH-1: Decide's static transport key (workspace plane + internal cron)."
    dictateApiKey:
      type: apiKey
      in: header
      name: x-dictate-api-key
      description: "AUTH-1: Dictate's static transport key. Fail-closed."
    workspaceToken:
      type: apiKey
      in: header
      name: X-Workspace-Token
      description: >-
        AUTH-4 identity JWT minted by the workspace: HS256, iss
        `workspace.xray.tech`, aud = the target service id (`blob`,
        `guardian`, `decide`), subject email (+ `companyId` where the service
        is org-scoped), exp−iat ≤ 300s. Services verify with a constant-time
        signature check, reject `alg:none`/algorithm confusion, and never
        trust a forgeable `?email=` in its place.
    eventSignature:
      type: apiKey
      in: header
      name: X-XR-Signature
      description: >-
        AUTH-2: v1=<hex HMAC-SHA256 of `<X-XR-Timestamp>.<raw body>`> with the
        producer's own signing key; timestamp within ±300s.
    contextSignature:
      type: apiKey
      in: header
      name: X-XR-Signature
      description: >-
        AUTH-6: v1=<hex HMAC-SHA256 of `<X-XR-Timestamp>.<raw query string>`>
        with the service's own context key; the service names itself in
        X-Workspace-Service-Id.
    cronSecret:
      type: apiKey
      in: header
      name: X-Cron-Secret
      description: "AUTH-5: shared secret for scheduled internal jobs."
    decideCallerKey:
      type: http
      scheme: bearer
      bearerFormat: dcd_…
      description: Decide-issued org-scoped caller API key (hashed at rest).
    guardianPublicKey:
      type: http
      scheme: bearer
      bearerFormat: xrg_live_…
      description: >-
        Guardian public consumer key, scoped to the key owner's org. The
        whole plane is OFF unless GUARDIAN_PUBLIC_API_ENABLED=true (404 when off).
    guardianAdminEmail:
      type: apiKey
      in: header
      name: x-guardian-admin-email
      description: Guardian admin allowlist (GUARDIAN_ADMIN_EMAILS) for consumer-key administration.
    dictateUserEmail:
      type: apiKey
      in: header
      name: X-Dictate-User-Email
      description: >-
        Dictate's subject scoping — the acting user's email (header, `?email=`
        or JSON body). Dictate has no AUTH-4 JWT yet; combine with
        `dictateApiKey`.
    forlocoInternalAuth:
      type: apiKey
      in: header
      name: internalauthorization
      description: >-
        ForLoCo's internal shared key (HOURLY_INTERNAL_KEY twin), constant-time
        compared; the plane is feature-gated (404 when FEATURE_WORKSPACE_EMBED
        is off, 503 when the key is unset).

  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: One of the x-error-codes taxonomy (service-specific codes allowed).
            message:
              type: string
            details:
              description: Optional structured validation detail (Blob/Decide).
    InboundEvent:
      type: object
      required: [id, type, service, occurredAt, subject, data, contractVersion]
      properties:
        id:
          type: string
          description: Producer ULID; the dedupe key.
        type:
          type: string
          description: A type the catalog assigns to `service`.
        service:
          type: string
        occurredAt:
          type: string
          format: date-time
        subject:
          type: object
          required: [email]
          properties:
            email:
              type: string
              format: email
            companyId:
              type: string
        data:
          description: Validated against the per-type schema (spec §7.3).
        contractVersion:
          const: v1
    SubjectContextOrgUnitRef:
      type: object
      description: A department or department-role reference (id + stable slug + display name).
      required: [id, slug, name]
      properties:
        id:
          type: string
        slug:
          type: string
        name:
          type: string
    SubjectContext:
      type: object
      required: [subject, permissions, preferences, asOf]
      properties:
        subject:
          type: object
          required: [email, tier]
          properties:
            email:
              type: string
              format: email
            companyId:
              type: string
            tier:
              type: string
              enum: [free, hourly, monthly]
        permissions:
          type: object
          description: Org role, workspace-admin flag, per-service access/maxScope.
        access:
          type: object
          description: >-
            OMITTED unless the caller's field profile includes it (and the
            FEATURE_ACCESS_CLAIMS rollout flag is on). The subject's own
            access facts — full department/custom-role assignment set (slugs)
            and effective permission keys, tier-intersected and filtered to
            the calling service's namespace (mirrors the AUTH-4 `perms`
            claim). Advisory: services re-check, never widen.
          required: [orgRole, departments, roles, permissions]
          properties:
            orgRole:
              type: string
              description: Workspace org role (OWNER/ADMIN/MEMBER/VIEWER).
            departments:
              type: array
              items:
                type: string
            roles:
              type: array
              items:
                type: string
            permissions:
              type: array
              items:
                type: string
        toolBelt:
          type: array
          items:
            type: string
          description: OMITTED unless the caller's field profile includes it.
        team:
          type: object
          description: OMITTED unless the caller's profile includes it.
          required: [organizationId, name, members]
          properties:
            organizationId:
              type: string
            name:
              type: string
            members:
              type: array
              items:
                type: object
                required: [email, name, role, department, departmentRole]
                properties:
                  email:
                    type: [string, "null"]
                  name:
                    type: [string, "null"]
                  role:
                    type: string
                    description: Workspace org role (OWNER/ADMIN/MEMBER/VIEWER).
                  department:
                    description: >-
                      Primary department (dept-roles plan §3 Phase B); null
                      when unassigned.
                    oneOf:
                      - $ref: "#/components/schemas/SubjectContextOrgUnitRef"
                      - type: "null"
                  departmentRole:
                    description: >-
                      Role held inside `department`; never non-null while
                      `department` is null.
                    oneOf:
                      - $ref: "#/components/schemas/SubjectContextOrgUnitRef"
                      - type: "null"
        preferences:
          type: object
          properties:
            serviceVisible:
              type: object
        asOf:
          type: string
          format: date-time
    EventCatalogEntry:
      type: object
      required: [type, producer]
      properties:
        type:
          type: string
          description: e.g. `guardian.ticket.detected`.
        producer:
          type: string
          description: One of the registered producer service ids.
        isNotification:
          type: boolean
    ServiceManifestFirstParty:
      type: object
      description: >-
        The manifest shape first-party services (blob, guardian, decide) serve
        today and the workspace's manifest cache consumes
        (src/lib/workspace/service-manifest.ts).
      required: [service, displayName, contractVersion, requiredTier]
      properties:
        service:
          type: string
        displayName:
          type: string
        tagline:
          type: string
          maxLength: 140
        contractVersion:
          const: v1
        requiredTier:
          type: string
          enum: [free, hourly, monthly]
        addOn:
          type: boolean
        workspaceRoute:
          type: string
          description: Must start with `/`.
        capabilities:
          type: object
          properties:
            summary: { type: boolean }
            activity: { type: boolean }
            search: { type: boolean }
            notifications: { type: boolean }
            deepLinks: { type: boolean }
            pages: { type: boolean }
            embed:
              type: [object, "null"]
              properties:
                url: { type: string }
        emittedEvents:
          type: array
          items: { type: string }
        consumesFacts:
          type: array
          items: { type: string }
        webhook:
          type: object
          properties:
            retrySchedule:
              type: array
              items: { type: integer }
              description: Seconds between redelivery attempts.
    ServiceManifestSelfServe:
      type: object
      description: >-
        The manifest shape SELF-SERVE registered services submit
        (src/lib/platform/manifest-schema.ts) — author-controlled REQUESTS the
        platform validates and admins grant. NOTE — the first-party and
        self-serve manifest schemas have not been reconciled yet; a service's
        GET /api/v1/manifest answers with one of the two shapes.
      required: [service, displayName, contractVersion, capabilities, certification]
      properties:
        service:
          type: string
          description: Must equal the registered slug (TRUST-1 anti-spoofing).
        displayName:
          type: string
        contractVersion:
          const: v1
        tagline: { type: string }
        description: { type: string }
        requiredTier:
          type: string
          enum: [free, hourly, monthly]
        capabilities:
          type: object
          properties:
            pages: { type: boolean }
            events: { type: boolean }
            ai:
              type: object
              properties:
                paths:
                  type: array
                  items: { type: string }
        requestedGrants:
          type: object
          properties:
            identity:
              type: object
              properties:
                companyId: { type: boolean }
                tier: { type: boolean }
                tools: { type: boolean }
            facts:
              type: array
              items: { type: string }
            events:
              type: array
              items: { type: string }
            embed: { type: boolean }
            byokRelayPaths:
              type: array
              items: { type: string }
        certification:
          type: object
          required: [resourcePath, healthPath]
          properties:
            resourcePath: { type: string }
            recordPathTemplate:
              type: string
              description: Contains `{id}`.
            healthPath: { type: string }
    HealthStatus:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [status]
          properties:
            status:
              type: string
              enum: [ok]
    VersionInfo:
      type: object
      properties:
        data:
          type: object
          properties:
            service: { type: string }
            contractVersion: { type: string }
            build: { type: string }
    PageDocument:
      type: object
      description: >-
        Server-driven UI page (BLOCK-CONTRACT.md; executable truth
        src/lib/platform/sdui/block-schema.ts). Summary depth here — blocks are
        a discriminated union on `type`.
      required: [id, title, blocks]
      properties:
        id: { type: string }
        title: { type: string }
        description: { type: string }
        blocks:
          type: array
          items:
            $ref: "#/components/schemas/SduiBlock"
    SduiBlock:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [heading, text, stat, list, table, form, actionRow, callout]
          description: >-
            heading/text — copy; stat — labeled figure; list/table — rows;
            form — inputs posting to an actionId; actionRow — buttons invoking
            POST /api/v1/actions/{actionId}; callout — highlighted notice.
      additionalProperties: true
    Ticket:
      type: object
      description: Guardian error ticket (core fields; org-scoped).
      required: [id, status]
      properties:
        id: { type: string }
        status:
          type: string
          enum: [open, acknowledged, resolved]
        summary: { type: string }
        automationId: { type: string }
        groupId: { type: string }
        assignee: { type: string }
        detectedAt:
          type: string
          format: date-time

    # --- decide domain schemas (ported) ---
    DecisionStatus:
      type: string
      enum:
        - pending
        - needs_info
        - resolved
        - expired
        - cancelled
    Urgency:
      type: string
      enum:
        - normal
        - urgent
    ResolutionKind:
      type: string
      enum:
        - chosen
        - declined
        - expired
        - expired_default
    DecisionOptionCreate:
      type: object
      required:
        - label
      properties:
        label:
          type: string
          minLength: 1
          maxLength: 120
        description:
          type: string
          maxLength: 1000
        consequence:
          type: string
          maxLength: 1000
          description: e.g. 'Choosing this will…'
        default:
          type: boolean
          description: At most one option; requires expiresAt on the decision.
    DecisionOption:
      allOf:
        - $ref: "#/components/schemas/DecisionOptionCreate"
        - type: object
          required:
            - id
          properties:
            id:
              type: string
    Fact:
      type: object
      required:
        - label
        - value
      properties:
        label:
          type: string
          maxLength: 80
        value:
          type: string
          maxLength: 400
    Link:
      type: object
      required:
        - label
        - url
      properties:
        label:
          type: string
          maxLength: 80
        url:
          type: string
          format: uri
          description: https only
    DecisionCreate:
      type: object
      required:
        - title
        - roleSlug
        - options
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        roleSlug:
          type: string
          pattern: ^[a-z0-9]+(-[a-z0-9]+)*$
          maxLength: 50
        body:
          type: string
          maxLength: 32768
          description: Markdown (rendered sanitized; raw HTML never rendered)
        facts:
          type: array
          maxItems: 20
          items:
            $ref: "#/components/schemas/Fact"
        links:
          type: array
          maxItems: 10
          items:
            $ref: "#/components/schemas/Link"
        options:
          type: array
          minItems: 2
          maxItems: 5
          items:
            $ref: "#/components/schemas/DecisionOptionCreate"
        urgency:
          $ref: "#/components/schemas/Urgency"
        expiresAt:
          type: string
          format: date-time
    InfoRequest:
      type: object
      required:
        - id
        - question
        - askedBy
        - askedAt
      properties:
        id:
          type: string
        question:
          type: string
          maxLength: 2000
        askedBy:
          type: string
          format: email
        askedAt:
          type: string
          format: date-time
        answer:
          type: string
          maxLength: 8192
        answeredAt:
          type: string
          format: date-time
    Resolution:
      type: object
      required:
        - kind
      properties:
        kind:
          $ref: "#/components/schemas/ResolutionKind"
        optionId:
          type: string
        rationale:
          type: string
          maxLength: 2000
        decidedBy:
          type: string
          description: email, or system:expiry
        decidedAt:
          type: string
          format: date-time
    AssignmentChange:
      type: object
      required:
        - actor
        - at
      properties:
        fromRole:
          type: string
        toRole:
          type: string
        toEmail:
          type: string
          format: email
        actor:
          type: string
        at:
          type: string
          format: date-time
        note:
          type: string
          maxLength: 500
    Decision:
      type: object
      required:
        - id
        - organizationId
        - callerName
        - roleSlug
        - status
        - title
        - options
        - urgency
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          description: ULID
        organizationId:
          type: string
        callerName:
          type: string
        roleSlug:
          type: string
        status:
          $ref: "#/components/schemas/DecisionStatus"
        title:
          type: string
        body:
          type: string
        facts:
          type: array
          items:
            $ref: "#/components/schemas/Fact"
        links:
          type: array
          items:
            $ref: "#/components/schemas/Link"
        options:
          type: array
          items:
            $ref: "#/components/schemas/DecisionOption"
        urgency:
          $ref: "#/components/schemas/Urgency"
        expiresAt:
          type: string
          format: date-time
        defaultOptionId:
          type: string
        directAssigneeEmail:
          type: string
          format: email
        resolution:
          $ref: "#/components/schemas/Resolution"
        infoRequests:
          type: array
          items:
            $ref: "#/components/schemas/InfoRequest"
        assignmentHistory:
          type: array
          items:
            $ref: "#/components/schemas/AssignmentChange"
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    Role:
      type: object
      required:
        - id
        - slug
        - name
        - createdAt
      properties:
        id:
          type: string
        slug:
          type: string
        name:
          type: string
        description:
          type: string
        memberCount:
          type: integer
        createdAt:
          type: string
          format: date-time
        archivedAt:
          type: string
          format: date-time
    RoleCreate:
      type: object
      required:
        - slug
        - name
      properties:
        slug:
          type: string
          pattern: ^[a-z0-9]+(-[a-z0-9]+)*$
          maxLength: 50
        name:
          type: string
          minLength: 1
          maxLength: 80
        description:
          type: string
          maxLength: 400
    Assignment:
      type: object
      required:
        - memberEmail
        - addedBy
        - addedAt
      properties:
        memberEmail:
          type: string
          format: email
        addedBy:
          type: string
          format: email
        addedAt:
          type: string
          format: date-time
    ApiKey:
      type: object
      required:
        - id
        - name
        - keyPrefix
        - createdBy
        - createdAt
      properties:
        id:
          type: string
        name:
          type: string
        keyPrefix:
          type: string
          description: e.g. dcd_a1b2
        createdBy:
          type: string
          format: email
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type: string
          format: date-time
        revokedAt:
          type: string
          format: date-time
    DecisionEnvelope:
      type: object
      required:
        - data
      properties:
        data:
          type: object
          required:
            - decision
          properties:
            decision:
              $ref: "#/components/schemas/Decision"
    DecisionListEnvelope:
      type: object
      required:
        - data
      properties:
        data:
          type: object
          required:
            - decisions
          properties:
            decisions:
              type: array
              items:
                $ref: "#/components/schemas/Decision"
            nextCursor:
              type: string
    RoleEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            role:
              $ref: "#/components/schemas/Role"
    RoleListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            roles:
              type: array
              items:
                $ref: "#/components/schemas/Role"
    AssignmentListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            assignments:
              type: array
              items:
                $ref: "#/components/schemas/Assignment"
    KeyEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            key:
              $ref: "#/components/schemas/ApiKey"
    KeyListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            keys:
              type: array
              items:
                $ref: "#/components/schemas/ApiKey"
    KeyCreatedEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            key:
              $ref: "#/components/schemas/ApiKey"
            plaintext:
              type: string
              description: Full key value — returned ONLY in create/rotate responses.
    WebhookEndpoint:
      type: object
      required:
        - id
        - url
        - createdAt
      properties:
        id:
          type: string
        url:
          type: string
          format: uri
        createdAt:
          type: string
          format: date-time
        disabledAt:
          type: string
          format: date-time
    WebhookCreatedEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            webhook:
              $ref: "#/components/schemas/WebhookEndpoint"
            secret:
              type: string
              description: Signing secret — returned ONCE.
    WebhookListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            webhooks:
              type: array
              items:
                $ref: "#/components/schemas/WebhookEndpoint"

  parameters:
    XrTimestamp:
      name: X-XR-Timestamp
      in: header
      required: true
      schema: { type: string }
      description: Unix-seconds timestamp bound into the HMAC; ±300s skew.
    WorkspaceServiceId:
      name: X-Workspace-Service-Id
      in: header
      required: true
      schema:
        type: string
        enum: [guardian, blob, tasks]
      description: The calling service names itself; paired with its context key.

    # --- decide parameters (ported) ---
    DecisionId:
      name: id
      in: path
      required: true
      schema:
        type: string
        description: ULID
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        maxLength: 255
    Cursor:
      name: cursor
      in: query
      schema:
        type: string
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25

  responses:
    ErrorUnauthorized:
      description: Missing/invalid credential (UNAUTHORIZED).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    ErrorNotFound:
      description: Missing or not owned — never reveal which (NOT_FOUND).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    ErrorRateLimited:
      description: Throttled; retry with backoff (RATE_LIMITED).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    ErrorNotConfigured:
      description: Service env not set (NOT_CONFIGURED, permanent).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

    # --- decide responses (ported) ---
    Unauthorized:
      description: Missing/invalid credential
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    NotFound:
      description: Not found (or not owned by this org)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    Conflict:
      description: invalid_transition or idempotency_conflict
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    RateLimited:
      description: Rate limited (Retry-After set)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    PayloadTooLarge:
      description: Body exceeds bounds (e.g. markdown > 32KiB)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    ValidationOrUnknownRole:
      description: |
        validation_error, or unknown_role with `details.validRoles: string[]`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

# Reusable per-service server groups (YAML anchors; aliases used by path items).
# Server variables let one file target production (defaults), staging or local.
x-server-groups:
  blob: &blobServers
    - url: "{blobBaseUrl}"
      description: Blob
      variables:
        blobBaseUrl:
          default: https://xr-blob.up.railway.app
  guardian: &guardianServers
    - url: "{guardianBaseUrl}"
      description: Guardian
      variables:
        guardianBaseUrl:
          default: https://xr-guardian.up.railway.app
  decide: &decideServers
    - url: "{decideBaseUrl}"
      description: Decide
      variables:
        decideBaseUrl:
          default: https://xr-decide-production.up.railway.app
  dictate: &dictateServers
    - url: "{dictateBaseUrl}"
      description: Dictate
      variables:
        dictateBaseUrl:
          default: https://xr-dictate-api-production.up.railway.app
  forloco: &forlocoServers
    - url: "{forlocoBaseUrl}"
      description: ForLoCo (Hourly)
      variables:
        forlocoBaseUrl:
          default: https://for-lo-co-production.up.railway.app
  serviceContract: &serviceContractServers
    - url: "{blobBaseUrl}"
      description: Blob
      variables:
        blobBaseUrl:
          default: https://xr-blob.up.railway.app
    - url: "{guardianBaseUrl}"
      description: Guardian
      variables:
        guardianBaseUrl:
          default: https://xr-guardian.up.railway.app
    - url: "{decideBaseUrl}"
      description: Decide
      variables:
        decideBaseUrl:
          default: https://xr-decide-production.up.railway.app

paths:
  # ===========================================================================
  # workspace — service-facing endpoints on the hub (global server)
  # ===========================================================================
  /api/webhooks/events:
    post:
      operationId: workspaceDeliverEvent
      tags: [workspace]
      summary: Deliver one signed event (at-least-once; dedupe on (service, id))
      security:
        - eventSignature: []
      parameters:
        - $ref: "#/components/parameters/XrTimestamp"
        - name: X-XR-Event-Id
          in: header
          required: true
          schema: { type: string }
        - name: X-XR-Delivery
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InboundEvent" }
      responses:
        "202": { description: Accepted (handled after ack). }
        "200": { description: Duplicate — already processed; stop retrying. }
        "400":
          description: Permanent (unknown type, invalid data) — alert, never retry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { description: Bad/stale signature. }
        "413": { description: Body over 64KB. }
        "429": { description: Per-service quota; retry with backoff. }
  /api/v1/subject-context:
    get:
      operationId: workspaceGetSubjectContext
      tags: [workspace]
      summary: Pull one user's cross-service context (field-filtered per caller)
      security:
        - contextSignature: []
      parameters:
        - name: email
          in: query
          required: true
          schema: { type: string, format: email }
        - $ref: "#/components/parameters/WorkspaceServiceId"
        - $ref: "#/components/parameters/XrTimestamp"
      responses:
        "200":
          description: The caller's field-entitled view; cache on `asOf`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SubjectContext" }
        "401": { description: Bad/stale signature or unknown service (identical — no oracle). }
        "404": { description: No workspace user with that email. }
        "429": { description: Per-service quota. }
        "501": { description: No context keys configured. }
  /api/v1/event-catalog:
    get:
      operationId: workspaceGetEventCatalog
      tags: [workspace]
      summary: The event-type registry (public, schema-only)
      security: []
      parameters:
        - name: service
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Registered types and their owning producers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contractVersion: { const: v1 }
                      producers:
                        type: array
                        items: { type: string }
                      types:
                        type: array
                        items: { $ref: "#/components/schemas/EventCatalogEntry" }
  /api/v1/service-data/{provider}:
    get:
      operationId: workspaceGetServiceData
      tags: [workspace]
      summary: >-
        Cross-service data plane: read search/blob/guardian data through the
        workspace (AUTH-6 signed, per-caller provider profile,
        subject-entitlement gated)
      security:
        - contextSignature: []
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
            enum: [search, blob, guardian]
        - name: email
          in: query
          required: false
          description: Subject email — required for the user-scoped providers (blob, guardian).
          schema: { type: string, format: email }
        - name: q
          in: query
          required: false
          description: Catalog query (min 2 chars) — required for provider `search`.
          schema: { type: string, minLength: 2, maxLength: 200 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 25 }
        - $ref: "#/components/parameters/WorkspaceServiceId"
        - $ref: "#/components/parameters/XrTimestamp"
      responses:
        "200":
          description: >-
            Provider payload: `search` → catalog results; `blob` → summary +
            schema-only data-source index; `guardian` → summary + org metrics.
        "401": { description: Bad/stale signature or unknown service (identical — no oracle). }
        "403":
          description: >-
            Caller's provider profile excludes this provider, or the subject is
            not entitled to the product.
        "404": { description: Unknown provider or no workspace user with that email. }
        "429": { description: Per-service quota. }
        "501": { description: No context keys configured. }
  /api/v1/service-data/recordings/grant:
    post:
      operationId: workspaceGrantRecordingAccess
      tags: [workspace]
      summary: >-
        Grant or revoke a prospect's playback access to a Debrief recording, on
        behalf of the extracted Onboarding service (AUTH-2 inbound signature)
      description: >-
        The onboarding portal runs as its own service, but `RecordingInvite`
        and `RecordingShareSettings` belong to Debrief and stayed in the
        workspace — so arming an invite, previously a local insert, is now an
        authenticated call in. Only `onboarding-portal` may call it; any other
        service id gets the same opaque 401 as a bad signature. Every action is
        idempotent, because the caller retries a grant whose response it never
        saw rather than shipping a portal whose recording will not play.
      security:
        - contextSignature: []
      parameters:
        - $ref: "#/components/parameters/WorkspaceServiceId"
        - $ref: "#/components/parameters/XrTimestamp"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recordingId, email, grantedByEmail]
              properties:
                recordingId: { type: string, minLength: 1, maxLength: 64 }
                email:
                  type: string
                  format: email
                  maxLength: 254
                  description: The prospect the grant is issued to.
                grantedByEmail:
                  type: string
                  format: email
                  maxLength: 254
                  description: >-
                    The operator who armed or revoked the invite. Recorded as
                    the audit actor — attribution must survive the extraction.
                action:
                  type: string
                  enum: [grant, revoke]
                  default: grant
      responses:
        "200":
          description: Applied (or already in that state — the call is idempotent).
        "400": { description: Body is not JSON, or fails validation. }
        "401": { description: Bad/stale signature or unknown service (identical — no oracle). }
        "429": { description: Per-caller rate limit. }
        "501": { description: No signing key configured — fails closed, accepting nothing. }
  /api/v1/openapi:
    get:
      operationId: workspaceGetOpenApi
      tags: [workspace]
      summary: This document (JSON by default; `?format=yaml` or Accept application/yaml)
      security: []
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [json, yaml]
      responses:
        "200":
          description: The canonical platform OpenAPI document.
          content:
            application/json: {}
            application/yaml: {}
        "429": { description: Rate limited (60/min). }

  # ===========================================================================
  # service-contract — the uniform /api/v1 plane (blob + guardian + decide).
  # Dictate does not implement this plane yet; the day it does, add it to
  # x-service here (the conformance gate will demand it).
  # ===========================================================================
  /api/v1/health:
    x-service: [blob, guardian, decide]
    servers: *serviceContractServers
    get:
      operationId: serviceHealth
      tags: [service-contract]
      summary: Liveness (unauthenticated)
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }
  /api/v1/ready:
    x-service: [blob, guardian, decide]
    servers: *serviceContractServers
    get:
      operationId: serviceReady
      tags: [service-contract]
      summary: Readiness — dependencies reachable (unauthenticated)
      security: []
      responses:
        "200": { description: Ready. }
        "503": { description: Not ready (envelope). }
  /api/v1/manifest:
    x-service: [blob, guardian, decide]
    servers: *serviceContractServers
    get:
      operationId: serviceManifest
      tags: [service-contract]
      summary: Capability manifest (AUTH-1; keyless answers 401 in the error envelope)
      security:
        - blobApiKey: []
        - guardianApiKey: []
        - decideApiKey: []
      responses:
        "200":
          description: The service's manifest (one of the two platform shapes).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/ServiceManifestFirstParty"
                      - $ref: "#/components/schemas/ServiceManifestSelfServe"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "503": { description: AUTH-1 key unset in production (fail-closed). }
  /api/v1/version:
    x-service: [blob, guardian, decide]
    servers: *serviceContractServers
    get:
      operationId: serviceVersion
      tags: [service-contract]
      summary: Build/contract diagnostics
      security: []
      responses:
        "200":
          description: Version info.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VersionInfo" }
  /api/v1/summary:
    x-service: [blob, guardian]
    servers: *serviceContractServers
    get:
      operationId: serviceSummary
      tags: [service-contract]
      summary: Workspace summary card for the acting subject
      security:
        - blobApiKey: []
          workspaceToken: []
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` summary payload." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/activity:
    x-service: [blob, guardian]
    servers: *serviceContractServers
    get:
      operationId: serviceActivity
      tags: [service-contract]
      summary: Recent activity feed for the acting subject
      security:
        - blobApiKey: []
          workspaceToken: []
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` activity payload." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/search:
    x-service: [blob, guardian]
    servers: *serviceContractServers
    get:
      operationId: serviceSearch
      tags: [service-contract]
      summary: Federated search within the service, scoped to the acting subject
      security:
        - blobApiKey: []
          workspaceToken: []
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` scored hits." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/facts:
    x-service: [blob, guardian]
    servers: *serviceContractServers
    post:
      operationId: serviceConsumeFacts
      tags: [service-contract]
      summary: Consume brokered workspace facts (e.g. user.tier.changed)
      security:
        - eventSignature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fact]
              properties:
                fact: { type: string }
                subject:
                  type: object
                  properties:
                    email: { type: string, format: email }
                    companyId: { type: string }
                data: { type: object }
      responses:
        "202": { description: Fact accepted. }
        "400": { description: Unknown fact (envelope). }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/pages:
    x-service: [blob, guardian]
    servers: *serviceContractServers
    get:
      operationId: serviceListPages
      tags: [service-contract]
      summary: Server-driven UI — list available PageDocuments
      security:
        - blobApiKey: []
          workspaceToken: []
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: PageDocument[] }` (see PageDocument)." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/pages/{id}:
    x-service: [blob, guardian]
    servers: *serviceContractServers
    get:
      operationId: serviceGetPage
      tags: [service-contract]
      summary: Server-driven UI — one PageDocument
      security:
        - blobApiKey: []
          workspaceToken: []
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PageDocument" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/actions/{actionId}:
    x-service: [blob, guardian]
    servers: *serviceContractServers
    post:
      operationId: serviceExecuteAction
      tags: [service-contract]
      summary: Execute a workspace action declared by an SDUI page
      security:
        - blobApiKey: []
          workspaceToken: []
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: actionId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: Action input (validated per action).
      responses:
        "200": { description: "`{ data: … }` action result." }
        "400": { description: Validation failure (envelope). }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }

  # --- workspace: ForLoCo → workspace tier-sync webhook (machine contract) ---
  /api/webhooks/tier-sync:
    post:
      operationId: workspaceTierSync
      tags: [workspace]
      summary: ForLoCo billing → workspace tier sync (signed; replay-guarded)
      security:
        - eventSignature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, tier]
              properties:
                email: { type: string, format: email }
                tier:
                  type: string
                  enum: [free, hourly, monthly]
      responses:
        "200": { description: Tier updated (previous tier audited). }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "404": { description: No registry user with that email. }

  # ===========================================================================
  # blob — unique machine surface (AUTH-1 X-Blob-Api-Key; identity AUTH-4)
  # ===========================================================================
  /api/v1/insights:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobListInsights
      tags: [blob]
      summary: Deterministic schema insights for the acting subject
      security:
        - blobApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` findings (code-keyed, table/field-anchored)." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/insights/chat:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobInsightsChat
      tags: [blob]
      summary: Schema-only AI chat about the subject's indexed schemas
      security:
        - blobApiKey: []
          workspaceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string }
                threadId: { type: string }
      responses:
        "200": { description: "`{ data: … }` assistant reply (consent-gated)." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "501": { $ref: "#/components/responses/ErrorNotConfigured" }
  /api/v1/connectors:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobListConnectors
      tags: [blob]
      summary: List available data-source connectors
      security:
        - blobApiKey: []
      responses:
        "200": { description: "`{ data: … }` connector registry (13 sources)." }
  /api/v1/connectors/{type}/schema:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobConnectorExtractSchema
      tags: [blob]
      summary: Extract a normalized, structure-only schema via one connector
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: type
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Connector-specific credentials/locator (never row data).
      responses:
        "200": { description: "`{ data: … }` normalized schema." }
        "400": { description: Validation failure (envelope). }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/connectors/{type}/resources:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobConnectorListResources
      tags: [blob]
      summary: List the resources (bases/sheets/databases) a credential can see
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: type
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: "`{ data: … }` resource list." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/connectors/{type}/save:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobConnectorSave
      tags: [blob]
      summary: Persist a connector extraction as an indexed schema
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: type
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: "`{ data: … }` saved schema reference." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/connectors/requests:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobRequestConnector
      tags: [blob]
      summary: Request a connector Blob doesn't ship yet
      security:
        - blobApiKey: []
          workspaceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [connector]
              properties:
                connector: { type: string }
                notes: { type: string }
      responses:
        "201": { description: Request recorded. }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/schemas:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobListSchemas
      tags: [blob]
      summary: The subject's indexed schema library (all connectors)
      security:
        - blobApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` schema index (structure-only)." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/schemas/{id}:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobGetSchema
      tags: [blob]
      summary: One indexed schema
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` schema detail." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/schemas/{id}/versions:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobListSchemaVersions
      tags: [blob]
      summary: Version history for one schema
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` versions." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/schemas/{id}/versions/{versionId}:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobGetSchemaVersion
      tags: [blob]
      summary: One schema version (diff source)
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: versionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` version payload." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/extractions:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobListExtractions
      tags: [blob]
      summary: List extraction jobs
      security:
        - blobApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` extractions." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
    post:
      operationId: blobCreateExtraction
      tags: [blob]
      summary: Start an extraction job
      security:
        - blobApiKey: []
          workspaceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tokenId]
              properties:
                tokenId: { type: string }
                baseId: { type: string }
      responses:
        "201": { description: "`{ data: … }` extraction started." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/extractions/{id}:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobGetExtraction
      tags: [blob]
      summary: One extraction job
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` extraction detail." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/tokens:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobListTokens
      tags: [blob]
      summary: List the subject's data-source tokens
      security:
        - blobApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` tokens (never plaintext secrets)." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
    post:
      operationId: blobAddToken
      tags: [blob]
      summary: Add a data-source token (PAT)
      security:
        - blobApiKey: []
          workspaceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                label: { type: string }
      responses:
        "201": { description: "`{ data: … }` token stored." }
        "400": { description: Validation failure (envelope). }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/tokens/{id}:
    x-service: [blob]
    servers: *blobServers
    delete:
      operationId: blobRemoveToken
      tags: [blob]
      summary: Remove a data-source token
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Removed. }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/tokens/{id}/bases:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobListTokenBases
      tags: [blob]
      summary: Discover the bases/resources visible to one token
      security:
        - blobApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` base list." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/share-access:
    x-service: [blob]
    servers: *blobServers
    get:
      operationId: blobListShareAccess
      tags: [blob]
      summary: List share-access grants for the subject
      security:
        - blobApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` grants." }
    post:
      operationId: blobCreateShareAccess
      tags: [blob]
      summary: Record a shared-base access grant (service-account invite flow)
      security:
        - blobApiKey: []
          workspaceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "201": { description: Grant recorded. }
  /api/v1/share-access/process-pending:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobProcessPendingShareAccess
      tags: [blob]
      summary: Process pending shared-base grants (cron/ops)
      security:
        - blobApiKey: []
      responses:
        "200": { description: "`{ data: … }` processed count." }
  /api/v1/sync-shared-bases:
    x-service: [blob]
    servers: *blobServers
    post:
      operationId: blobSyncSharedBases
      tags: [blob, internal-cron]
      summary: Trigger the shared-base sync sweep (Railway cron)
      security:
        - cronSecret: []
        - blobApiKey: []
      responses:
        "200": { description: "`{ data: … }` sync summary." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }

  # ===========================================================================
  # guardian — unique machine surface (AUTH-1 + AUTH-4 aud guardian; org-scoped)
  # ===========================================================================
  /api/v1/dashboard:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianDashboard
      tags: [guardian]
      summary: Dashboard PageDocument for the acting subject's org
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: PageDocument }`." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/metrics:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianMetrics
      tags: [guardian]
      summary: Org error metrics
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` org metrics (org-scoped, never cross-tenant)." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/tickets:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListTickets
      tags: [guardian]
      summary: Error tickets for the subject's org
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200":
          description: Ticket list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Ticket" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /api/v1/tickets/{id}:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianGetTicket
      tags: [guardian]
      summary: One ticket
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Ticket detail.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Ticket" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/tickets/{id}/resolve:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianResolveTicket
      tags: [guardian]
      summary: Resolve a ticket
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: Ticket }` resolved." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/tickets/{id}/acknowledge:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianAcknowledgeTicket
      tags: [guardian]
      summary: Acknowledge a ticket
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: Ticket }` acknowledged." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/tickets/{id}/reassign:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianReassignTicket
      tags: [guardian]
      summary: Reassign a ticket to an org member
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assignee]
              properties:
                assignee: { type: string, format: email }
      responses:
        "200": { description: "`{ data: Ticket }` reassigned." }
        "400": { description: Unknown member (envelope). }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/tickets/{id}/notes:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListTicketNotes
      tags: [guardian]
      summary: Notes on a ticket
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` notes." }
    post:
      operationId: guardianAddTicketNote
      tags: [guardian]
      summary: Add a note to a ticket
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
      responses:
        "201": { description: Note added. }
  /api/v1/groups:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListGroups
      tags: [guardian]
      summary: Error groups (rollups)
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` groups." }
    post:
      operationId: guardianCreateGroup
      tags: [guardian]
      summary: Create an error group
      security:
        - guardianApiKey: []
          workspaceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201": { description: Group created. }
  /api/v1/groups/{id}:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianGetGroup
      tags: [guardian]
      summary: One group
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` group detail." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/groups/{id}/automations:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListGroupAutomations
      tags: [guardian]
      summary: Automations in a group
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` automations." }
  /api/v1/automations:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListAutomations
      tags: [guardian]
      summary: Indexed automations for the org
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` automations." }
  /api/v1/automations/{id}:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianGetAutomation
      tags: [guardian]
      summary: One automation
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` automation detail." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
    patch:
      operationId: guardianUpdateAutomation
      tags: [guardian]
      summary: Update an automation (owner/group)
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                owner: { type: string }
                groupId: { type: string }
      responses:
        "200": { description: "`{ data: … }` updated." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/automations/{id}/snooze:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianSnoozeAutomation
      tags: [guardian]
      summary: Snooze an automation's alerts
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                until: { type: string, format: date-time }
      responses:
        "200": { description: Snoozed. }
  /api/v1/events:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListErrorEvents
      tags: [guardian]
      summary: Error event log (org-scoped)
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` events." }
  /api/v1/ingest/error:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianIngestError
      tags: [guardian]
      summary: Route-A error ingestion (off by default — GUARDIAN_INGEST_ENABLED)
      security:
        - guardianApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provider error payload (normalized on ingest).
      responses:
        "202": { description: Ingested. }
        "404": { description: Ingestion disabled. }
  /api/v1/members:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListMembers
      tags: [guardian]
      summary: Org member directory (assignment targets)
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` members." }
  /api/v1/notifications/routes:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianListNotificationRoutes
      tags: [guardian]
      summary: Notification routes for the org
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: … }` routes." }
    post:
      operationId: guardianCreateNotificationRoute
      tags: [guardian]
      summary: Create a notification route (webhook/Slack/email)
      security:
        - guardianApiKey: []
          workspaceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, target]
              properties:
                kind:
                  type: string
                  enum: [webhook, slack, email, in-app]
                target: { type: string }
                filters: { type: object }
      responses:
        "201": { description: Route created. }
  /api/v1/notifications/routes/{id}:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianGetNotificationRoute
      tags: [guardian]
      summary: One notification route
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` route." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
    patch:
      operationId: guardianUpdateNotificationRoute
      tags: [guardian]
      summary: Update a route
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Updated. }
    delete:
      operationId: guardianDeleteNotificationRoute
      tags: [guardian]
      summary: Delete a route
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Deleted. }
  /api/v1/notifications/routes/{id}/test:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianTestNotificationRoute
      tags: [guardian]
      summary: Send a safe test notification through a route
      security:
        - guardianApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Test dispatched. }
  /api/v1/notifications/slack/install-url:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianSlackInstallUrl
      tags: [guardian]
      summary: Mint a Slack OAuth install URL (state-signed)
      security:
        - guardianApiKey: []
          workspaceToken: []
      responses:
        "200": { description: "`{ data: { url } }`." }
        "501": { $ref: "#/components/responses/ErrorNotConfigured" }
  /api/v1/notifications/slack/callback:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianSlackCallback
      tags: [guardian]
      summary: Slack OAuth callback (browser redirect target)
      security: []
      parameters:
        - name: code
          in: query
          required: false
          schema: { type: string }
        - name: state
          in: query
          required: false
          schema: { type: string }
      responses:
        "302": { description: Redirect back to the workspace. }
        "400": { description: Invalid state/code. }

  # --- guardian admin plane (consumer-key administration) ---
  /api/admin/v1/keys:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianAdminListKeys
      tags: [guardian-admin]
      summary: List public-API consumer keys
      security:
        - guardianAdminEmail: []
      responses:
        "200": { description: "`{ data: … }` keys (hashed at rest, prefixes only)." }
        "403": { description: Not on the admin allowlist. }
    post:
      operationId: guardianAdminCreateKey
      tags: [guardian-admin]
      summary: Create a consumer key (xrg_live_…)
      security:
        - guardianAdminEmail: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ownerOrg]
              properties:
                ownerOrg: { type: string }
                label: { type: string }
      responses:
        "201": { description: "`{ data: { key } }` — plaintext shown once." }
  /api/admin/v1/keys/{id}/revoke:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianAdminRevokeKey
      tags: [guardian-admin]
      summary: Revoke a consumer key
      security:
        - guardianAdminEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Revoked. }
  /api/admin/v1/keys/{id}/rotate:
    x-service: [guardian]
    servers: *guardianServers
    post:
      operationId: guardianAdminRotateKey
      tags: [guardian-admin]
      summary: Rotate a consumer key
      security:
        - guardianAdminEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: { key } }` — new plaintext shown once." }

  # --- guardian public consumer plane (default OFF) ---
  /api/public/v1/tickets:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianPublicListTickets
      tags: [guardian-public]
      summary: Read-only tickets for the key owner's org
      security:
        - guardianPublicKey: []
      responses:
        "200": { description: "`{ data: Ticket[] }`." }
        "404": { description: Public API disabled (GUARDIAN_PUBLIC_API_ENABLED). }
  /api/public/v1/groups:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianPublicListGroups
      tags: [guardian-public]
      summary: Read-only groups for the key owner's org
      security:
        - guardianPublicKey: []
      responses:
        "200": { description: "`{ data: … }`." }
        "404": { description: Public API disabled. }
  /api/public/v1/automations:
    x-service: [guardian]
    servers: *guardianServers
    get:
      operationId: guardianPublicListAutomations
      tags: [guardian-public]
      summary: Read-only automations for the key owner's org
      security:
        - guardianPublicKey: []
      responses:
        "200": { description: "`{ data: … }`." }
        "404": { description: Public API disabled. }

  # ===========================================================================
  # dictate — voice sessions → actions (AUTH-1 + user-email scoping; no JWT yet)
  # ===========================================================================
  /api/health:
    x-service: [dictate]
    servers: *dictateServers
    get:
      operationId: dictateHealth
      tags: [dictate]
      summary: Liveness (dictate's only health surface — not under /api/v1 yet)
      security: []
      responses:
        "200": { description: OK. }
  /api/v1/feed:
    x-service: [dictate]
    servers: *dictateServers
    get:
      operationId: dictateFeed
      tags: [dictate]
      summary: Recent dictation sessions for the acting user
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 20 }
      responses:
        "200": { description: "`{ data: … }` sessions feed." }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "503": { description: AUTH-1 key unset in production (fail-closed). }
  /api/v1/sessions:
    x-service: [dictate]
    servers: *dictateServers
    get:
      operationId: dictateListSessions
      tags: [dictate]
      summary: List the acting user's sessions
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      responses:
        "200": { description: "`{ data: { sessions } }`." }
    post:
      operationId: dictateCreateSession
      tags: [dictate]
      summary: Create a dictation session
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text:
                  type: string
                  description: Text brain-dump (audio uploads go to /audio).
      responses:
        "201": { description: "`{ data: { session } }`." }
  /api/v1/sessions/{id}:
    x-service: [dictate]
    servers: *dictateServers
    get:
      operationId: dictateGetSession
      tags: [dictate]
      summary: One session with its extracted actions
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` session detail." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/sessions/{id}/audio:
    x-service: [dictate]
    servers: *dictateServers
    post:
      operationId: dictateUploadAudio
      tags: [dictate]
      summary: Upload session audio (multipart; transcribed when OPENAI_API_KEY set)
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                audio:
                  type: string
                  format: binary
      responses:
        "200": { description: "`{ data: … }` transcription (or text-fallback notice)." }
  /api/v1/sessions/{id}/extract:
    x-service: [dictate]
    servers: *dictateServers
    post:
      operationId: dictateExtractActions
      tags: [dictate]
      summary: Extract structured actions from a session
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: { actions } }`." }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /api/v1/actions/{id}/approve:
    x-service: [dictate]
    servers: *dictateServers
    post:
      operationId: dictateApproveAction
      tags: [dictate]
      summary: Approve an extracted action
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Approved. }
  /api/v1/actions/{id}/execute:
    x-service: [dictate]
    servers: *dictateServers
    post:
      operationId: dictateExecuteAction
      tags: [dictate]
      summary: Execute an approved action (via Zapier MCP when connected)
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: "`{ data: … }` execution result." }
        "501": { $ref: "#/components/responses/ErrorNotConfigured" }
  /api/v1/actions/{id}/assign:
    x-service: [dictate]
    servers: *dictateServers
    post:
      operationId: dictateAssignAction
      tags: [dictate]
      summary: Assign an action to the tasks sink
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Assigned. }
  /api/v1/integrations/zapier:
    x-service: [dictate]
    servers: *dictateServers
    get:
      operationId: dictateGetZapierIntegration
      tags: [dictate]
      summary: The acting user's Zapier MCP connection state
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      responses:
        "200": { description: "`{ data: … }` connection state (never token material)." }
    post:
      operationId: dictateConnectZapier
      tags: [dictate]
      summary: Start a Zapier MCP OAuth connection
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      responses:
        "200": { description: "`{ data: { url } }` OAuth start." }
    delete:
      operationId: dictateDisconnectZapier
      tags: [dictate]
      summary: Disconnect Zapier (tokens destroyed)
      security:
        - dictateApiKey: []
          dictateUserEmail: []
      responses:
        "200": { description: Disconnected. }
  /api/v1/integrations/zapier/callback:
    x-service: [dictate]
    servers: *dictateServers
    post:
      operationId: dictateZapierCallback
      tags: [dictate]
      summary: Zapier OAuth callback (token stored encrypted at rest)
      security:
        - dictateApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Connected. }

  # ===========================================================================
  # forloco — machine surface (internal shared key + provider-signed webhooks)
  # ===========================================================================
  /api/internal/auth/sso:
    x-service: [forloco]
    servers: *forlocoServers
    post:
      operationId: forlocoMintEmbedSso
      tags: [forloco]
      summary: Mint a single-use (≤60s) embed SSO token for the workspace iframe
      security:
        - forlocoInternalAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Token minted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expiresAt: { type: string, format: date-time }
                  embedUrl: { type: string }
                  role: { type: string }
        "404": { description: Feature off (FEATURE_WORKSPACE_EMBED). }
        "503": { description: Internal key unset. }
  /api/internal/roster:
    x-service: [forloco]
    servers: *forlocoServers
    get:
      operationId: forlocoRoster
      tags: [forloco]
      summary: Engineer + client roster for workspace bubble-up
      security:
        - forlocoInternalAuth: []
      responses:
        "200":
          description: Roster.
          content:
            application/json:
              schema:
                type: object
                properties:
                  roster:
                    type: array
                    items:
                      type: object
                      required: [email, kind]
                      properties:
                        email: { type: string, format: email }
                        name: { type: string }
                        kind:
                          type: string
                          enum: [engineer, client]
  /api/webhooks/stripe:
    x-service: [forloco]
    servers: *forlocoServers
    post:
      operationId: forlocoStripeWebhook
      tags: [forloco]
      summary: Stripe events (signature-verified, idempotent)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Stripe event envelope (verified via Stripe-Signature header).
      responses:
        "200": { description: Processed. }
        "400": { description: Bad signature. }
  /api/webhooks/docusign:
    x-service: [forloco]
    servers: *forlocoServers
    post:
      operationId: forlocoDocusignWebhook
      tags: [forloco]
      summary: DocuSign envelope events
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Processed. }
  /api/webhooks/resend:
    x-service: [forloco]
    servers: *forlocoServers
    post:
      operationId: forlocoResendWebhook
      tags: [forloco]
      summary: Resend email delivery events
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Processed. }
  /api/webhooks/zoom/events:
    x-service: [forloco]
    servers: *forlocoServers
    post:
      operationId: forlocoZoomWebhook
      tags: [forloco]
      summary: Zoom meeting/recording events (webhook secret verified)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Processed. }
  /api/webhooks/airtable/booking-overage:
    x-service: [forloco]
    servers: *forlocoServers
    post:
      operationId: forlocoAirtableOverageWebhook
      tags: [forloco]
      summary: Airtable booking-overage hook (signed)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Processed. }

  # ===========================================================================
  # decide — ported from the retired docs/microservices/decide.openapi.yaml (retagged;
  # ops paths live in the shared service-contract plane above)
  # ===========================================================================
  /api/v1/decisions:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-caller
      summary: Create a decision
      description: |
        `Idempotency-Key` is REQUIRED. Replaying the same key with an identical
        payload returns the original 201; a different payload returns
        409 `idempotency_conflict`. An unknown `roleSlug` returns 400
        `unknown_role` with `error.details.validRoles`.
      security:
        - decideCallerKey: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DecisionCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "400":
          $ref: "#/components/responses/ValidationOrUnknownRole"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
      operationId: decidePostDecisions
    get:
      tags:
        - decide-caller
      summary: List the caller's decisions
      security:
        - decideCallerKey: []
      parameters:
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/DecisionStatus"
        - name: roleSlug
          in: query
          schema:
            type: string
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Page of decisions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionListEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
      operationId: decideGetDecisions
  /api/v1/decisions/{id}:
    x-service:
      - decide
    servers: *decideServers
    get:
      tags:
        - decide-caller
      summary: Get a decision (optionally long-polling)
      description: |
        With `?wait=1..60` the request is held until the decision's status
        changes or the wait elapses; either way the response is 200 with the
        current state and `X-Decide-Long-Poll: changed|timeout`.
        Long-poll is caller-plane only — the Workspace BFF never proxies `wait`.
      security:
        - decideCallerKey: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
        - name: wait
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 60
      responses:
        "200":
          description: Current decision state
          headers:
            X-Decide-Long-Poll:
              schema:
                type: string
                enum:
                  - changed
                  - timeout
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      operationId: decideGetDecisionsId
    delete:
      tags:
        - decide-caller
      summary: Cancel a decision (pending/needs_info only)
      security:
        - decideCallerKey: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
      responses:
        "200":
          description: Cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
      operationId: decideDeleteDecisionsId
  /api/v1/decisions/{id}/info-response:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-caller
      summary: Answer a decider's request for more information
      description:
        Returns the decision to `pending` and re-notifies assignees. 409 unless the decision is
        `needs_info`.
      security:
        - decideCallerKey: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - infoRequestId
                - answer
              properties:
                infoRequestId:
                  type: string
                answer:
                  type: string
                  maxLength: 8192
                  description: Markdown
      responses:
        "200":
          description: Back to pending
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
      operationId: decidePostDecisionsIdInfoResponse
  /api/v1/webhooks:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-caller
      summary: Register a webhook endpoint
      description: The signing `secret` is returned ONCE in this response.
      security:
        - decideCallerKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
              properties:
                url:
                  type: string
                  format: uri
                  description: https only
      responses:
        "201":
          description: Registered (secret included once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookCreatedEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
      operationId: decidePostWebhooks
    get:
      tags:
        - decide-caller
      summary: List webhook endpoints (secrets omitted)
      security:
        - decideCallerKey: []
      responses:
        "200":
          description: Endpoints
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookListEnvelope"
      operationId: decideGetWebhooks
  /api/v1/webhooks/{id}:
    x-service:
      - decide
    servers: *decideServers
    delete:
      tags:
        - decide-caller
      summary: Disable a webhook endpoint
      security:
        - decideCallerKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Disabled
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
      operationId: decideDeleteWebhooksId
  /api/v1/workspace/inbox:
    x-service:
      - decide
    servers: *decideServers
    get:
      tags:
        - decide-workspace
      summary: Decisions routed to the identified decider
      description: Decisions whose roleSlug the AUTH-4 `sub` is assigned to, plus direct assignments.
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/DecisionStatus"
        - name: roleSlug
          in: query
          schema:
            type: string
        - name: urgency
          in: query
          schema:
            $ref: "#/components/schemas/Urgency"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Page of decisions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionListEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
      operationId: decideGetWorkspaceInbox
  /api/v1/workspace/decisions/{id}:
    x-service:
      - decide
    servers: *decideServers
    get:
      tags:
        - decide-workspace
      summary: Decision detail (info thread + assignment history)
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
      responses:
        "200":
          description: Detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "404":
          $ref: "#/components/responses/NotFound"
      operationId: decideGetWorkspaceDecisionsId
  /api/v1/workspace/decisions/{id}/choose:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-workspace
      summary: Choose an option
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - optionId
              properties:
                optionId:
                  type: string
                rationale:
                  type: string
                  maxLength: 2000
      responses:
        "200":
          description: Resolved (chosen)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "409":
          $ref: "#/components/responses/Conflict"
      operationId: decidePostWorkspaceDecisionsIdChoose
  /api/v1/workspace/decisions/{id}/request-info:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-workspace
      summary: Ask the caller for more information
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - question
              properties:
                question:
                  type: string
                  minLength: 1
                  maxLength: 2000
      responses:
        "200":
          description: Now needs_info
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "409":
          $ref: "#/components/responses/Conflict"
      operationId: decidePostWorkspaceDecisionsIdRequestInfo
  /api/v1/workspace/decisions/{id}/reassign:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-workspace
      summary: Reassign to another role or member (exactly one of toRoleSlug/toEmail)
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                toRoleSlug:
                  type: string
                toEmail:
                  type: string
                  format: email
                note:
                  type: string
                  maxLength: 500
      responses:
        "200":
          description: Reassigned (status unchanged)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "400":
          $ref: "#/components/responses/ValidationOrUnknownRole"
        "409":
          $ref: "#/components/responses/Conflict"
      operationId: decidePostWorkspaceDecisionsIdReassign
  /api/v1/workspace/decisions/{id}/decline:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-workspace
      summary: Decline / abstain
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - $ref: "#/components/parameters/DecisionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                rationale:
                  type: string
                  maxLength: 2000
      responses:
        "200":
          description: Resolved (declined)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionEnvelope"
        "409":
          $ref: "#/components/responses/Conflict"
      operationId: decidePostWorkspaceDecisionsIdDecline
  /api/v1/workspace/stats:
    x-service:
      - decide
    servers: *decideServers
    get:
      tags:
        - decide-workspace
      summary: Pending/urgent counts for the identity
      security:
        - decideApiKey: []
          workspaceToken: []
      responses:
        "200":
          description: Counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      pendingCount:
                        type: integer
                      urgentCount:
                        type: integer
      operationId: decideGetWorkspaceStats
  /api/v1/workspace/roles:
    x-service:
      - decide
    servers: *decideServers
    get:
      tags:
        - decide-workspace
      summary: List decision roles for the org
      security:
        - decideApiKey: []
          workspaceToken: []
      responses:
        "200":
          description: Roles
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleListEnvelope"
      operationId: decideGetWorkspaceRoles
    post:
      tags:
        - decide-workspace
      summary: Create a decision role
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RoleCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleEnvelope"
      operationId: decidePostWorkspaceRoles
  /api/v1/workspace/roles/{id}:
    x-service:
      - decide
    servers: *decideServers
    patch:
      tags:
        - decide-workspace
      summary: Update a role (name/description; slug immutable)
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 80
                description:
                  type: string
                  maxLength: 400
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleEnvelope"
      operationId: decidePatchWorkspaceRolesId
    delete:
      tags:
        - decide-workspace
      summary: Archive a role (pending decisions keep their history)
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Archived
        "409":
          $ref: "#/components/responses/Conflict"
      operationId: decideDeleteWorkspaceRolesId
  /api/v1/workspace/roles/{id}/assignments:
    x-service:
      - decide
    servers: *decideServers
    get:
      tags:
        - decide-workspace
      summary: List member assignments for a role
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Assignments
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssignmentListEnvelope"
      operationId: decideGetWorkspaceRolesIdAssignments
    put:
      tags:
        - decide-workspace
      summary: Replace the full member-email list (self-healing on every save)
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - emails
              properties:
                emails:
                  type: array
                  maxItems: 100
                  items:
                    type: string
                    format: email
      responses:
        "200":
          description: Replaced
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssignmentListEnvelope"
      operationId: decidePutWorkspaceRolesIdAssignments
  /api/v1/workspace/keys:
    x-service:
      - decide
    servers: *decideServers
    get:
      tags:
        - decide-workspace
      summary: List caller API keys (prefix + metadata only)
      security:
        - decideApiKey: []
          workspaceToken: []
      responses:
        "200":
          description: Keys
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeyListEnvelope"
      operationId: decideGetWorkspaceKeys
    post:
      tags:
        - decide-workspace
      summary: Create a caller API key (plaintext returned once)
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 80
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeyCreatedEnvelope"
      operationId: decidePostWorkspaceKeys
  /api/v1/workspace/keys/{id}/rotate:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-workspace
      summary: Rotate a key (new plaintext returned once; old key revoked)
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Rotated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeyCreatedEnvelope"
      operationId: decidePostWorkspaceKeysIdRotate
  /api/v1/workspace/keys/{id}/revoke:
    x-service:
      - decide
    servers: *decideServers
    post:
      tags:
        - decide-workspace
      summary: Revoke a key
      security:
        - decideApiKey: []
          workspaceToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeyEnvelope"
      operationId: decidePostWorkspaceKeysIdRevoke

  /api/v1/internal/expire:
    x-service: [decide]
    servers: *decideServers
    post:
      operationId: decideInternalExpire
      tags: [internal-cron]
      summary: Expire overdue decisions (scheduled job)
      security:
        - decideApiKey: []
      responses:
        "200": { description: "`{ data: … }` expired count." }
  /api/v1/internal/drain:
    x-service: [decide]
    servers: *decideServers
    post:
      operationId: decideInternalDrain
      tags: [internal-cron]
      summary: Drain the outbound event outbox (scheduled job)
      security:
        - decideApiKey: []
      responses:
        "200": { description: "`{ data: … }` drained count." }
  /api/v1/mcp:
    x-service: [decide]
    servers: *decideServers
    post:
      operationId: decideMcp
      tags: [decide-caller]
      summary: MCP endpoint — request decisions from agent frameworks
      security:
        - decideCallerKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: JSON-RPC 2.0 `tools/call` envelope.
      responses:
        "200": { description: JSON-RPC response. }

# OpenAPI 3.1 webhooks — outbound deliveries a service can subscribe to.
webhooks:
  decisionUpdate:
    post:
      summary: Delivered to each registered caller endpoint on needs_info/resolved/expired/cancelled
      description: |
        Signed like the workspace inbound event bus:
        `X-Decide-Signature: v1=<hex HMAC-SHA256 over "<X-Decide-Timestamp>.<rawBody>">`,
        ±300s skew. 5 attempts with exponential backoff, then dead-letter.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required:
                - event
                - decision
              properties:
                event:
                  type: string
                  enum:
                    - needs_info
                    - resolved
                    - expired
                    - cancelled
                decision:
                  $ref: "#/components/schemas/Decision"
      responses:
        "200":
          description: Acknowledged (any 2xx)
