openapi: 3.1.0
info:
  title: WarnFire API
  version: 0.1.0
  description: >
    Source-of-truth API contract. Go server types and the shared TypeScript
    client (web + React Native) are generated from this file — see api/README.md.
servers:
  - url: http://localhost:8000
paths:
  /healthz:
    get:
      summary: Liveness probe
      operationId: getHealth
      responses:
        "200":
          description: Service is alive
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
  /readyz:
    get:
      summary: Readiness probe (checks the datastore)
      operationId: getReady
      responses:
        "200": { description: Ready }
        "503": { description: A dependency is unavailable }

  /v1/auth/config:
    get:
      summary: Discover public customer authentication capabilities
      operationId: getCustomerAuthConfig
      responses:
        "200":
          description: Public authentication configuration
          content:
            application/json:
              schema:
                type: object
                required: [signup_enabled]
                properties:
                  signup_enabled: { type: boolean }

  /v1/auth/signup:
    post:
      summary: Create a trial tenant and its initial owner atomically
      operationId: signupCustomer
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerSignup" }
      responses:
        "201":
          description: >
            Tenant and owner created. When email verification is enabled, no
            session is issued (email_verification_required=true, token empty) and
            the owner must verify before logging in; otherwise a 24-hour session
            token is returned.
          headers:
            Cache-Control: { schema: { type: string }, description: Always no-store }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerAuthResult" }
        "400": { description: Invalid signup fields }
        "404": { description: Self-service signup is disabled }
        "409": { description: Email or organization slug is unavailable }
        "429": { description: Signup rate limit exceeded }

  /v1/auth/login:
    post:
      summary: Exchange a customer email and password for a short-lived console session
      operationId: loginCustomer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email, maxLength: 254 }
                password: { type: string, minLength: 1, maxLength: 72, format: password }
      responses:
        "200":
          description: Authenticated 24-hour console session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerAuthResult" }
        "401": { description: Invalid email or password }
        "403": { description: "Email is not verified (body {\"error\":\"email_not_verified\"}); verification is enforced when email is enabled" }
        "429": { description: Login rate limit exceeded }

  /v1/auth/responder/login:
    post:
      summary: Exchange a tenant email and password for a mobile responder session
      operationId: loginResponder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email, maxLength: 254 }
                password: { type: string, minLength: 1, maxLength: 72, format: password }
      responses:
        "200":
          description: Authenticated 30-day responder session for secure device storage
          headers:
            Cache-Control: { schema: { type: string }, description: Always no-store }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResponderAuthResult" }
        "401": { description: Invalid email or password }
        "403": { description: Email unverified or no enabled responder profile with the same tenant email }
        "429": { description: Login rate limit exceeded }

  /v1/auth/responder/logout:
    post:
      summary: Revoke the current mobile responder session
      operationId: logoutResponder
      security: [{ responderToken: [] }]
      responses:
        "204": { description: Session revoked (idempotent) }

  /v1/auth/verify/confirm:
    post:
      summary: Confirm an email address with a verification token
      operationId: confirmEmailVerification
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [token], properties: { token: { type: string } } } } } }
      responses:
        "200": { description: Email verified }
        "400": { description: Invalid or expired token }
  /v1/auth/verify/resend:
    post:
      summary: Resend a verification email (uniform response; no account enumeration)
      operationId: resendEmailVerification
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string, format: email } } } } } }
      responses:
        "202": { description: If an unverified account exists, a verification email was sent }
        "429": { description: Rate limit exceeded }
  /v1/auth/password/forgot:
    post:
      summary: Request a password reset (uniform response; no account enumeration)
      operationId: requestPasswordReset
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string, format: email } } } } } }
      responses:
        "202": { description: If the account exists, a reset email was sent }
        "429": { description: Rate limit exceeded }
  /v1/auth/password/reset:
    post:
      summary: Set a new password with a reset token (revokes all sessions)
      operationId: resetPassword
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [token, password], properties: { token: { type: string }, password: { type: string, minLength: 12, maxLength: 72, format: password } } } } } }
      responses:
        "200": { description: Password reset; all existing sessions revoked }
        "400": { description: Invalid/expired token or password outside policy }

  /v1/events:
    post:
      summary: Ingest an event (Events API)
      description: >
        The universal ingestion primitive (PagerDuty-Events-v2 shaped). The
        `integration_key` authenticates the source and routes it to exactly one
        service. The deprecated `routing_key` alias remains accepted for
        PagerDuty-compatible senders during migration.
        Accepted events are committed durably before the response (fail loud,
        never silent). Idempotency is OPERATION-BOUND: a `client_event_id` is a
        replay only if it is reused with the SAME action and dedup key
        (canonical request). Reusing a `client_event_id` for a different action
        (e.g. a trigger's id as a resolve) or a different dedup key returns 409
        and never changes state — checked independently of whether the alert is
        currently active. A `trigger` opens or dedups into an incident and starts
        escalation; `resolve` clears the active alert and stops escalation;
        source `acknowledge` is recorded on the timeline but does not stop
        escalation. An unresolvable schedule never rolls back an accepted
        trigger: the incident is committed, the unusable step is recorded on
        its timeline, and escalation continues to later reachable responders.
      operationId: ingestEvent
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventIn" }
      responses:
        "202":
          description: Accepted and durably recorded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventAccepted" }
        "400": { description: Malformed or invalid event }
        "401": { description: Unknown, expired, revoked, or disabled integration key }
        "403":
          description: >
            The integration key is valid but its owning tenant is suspended. The
            response body is {"error":"tenant_suspended"}. Distinct from 401 so a
            tenant recovering from a billing lapse gets an actionable signal.
        "409":
          description: >
            client_event_id already used for a different action or dedup key
            (operation-bound idempotency conflict).
        "429":
          description: Per-integration event limit or maximum active incidents exceeded.

  /v1/integrations/webhooks/{adapter}:
    post:
      summary: Ingest a provider webhook through a registered adapter
      description: >
        Translates one provider delivery into one canonical WarnFire event and
        then uses the same validation, idempotency, tenant routing, rate limits,
        and incident lifecycle as /v1/events. The integration key must be sent
        as an Authorization Bearer credential or X-WarnFire-Integration-Key;
        credentials in the JSON body are rejected. The built-in `generic`
        adapter accepts GenericWebhookEvent and rejects unknown fields and
        trailing JSON values. The built-in `alertmanager` adapter accepts a
        Prometheus Alertmanager webhook notification (AlertmanagerWebhook,
        payload version 4) and maps the whole alert group to one event whose
        dedup key derives from the stable groupKey. Request bodies are limited
        to 256 KiB.
      operationId: ingestAdaptedWebhook
      security: [{ integrationKey: [] }]
      parameters:
        - name: adapter
          in: path
          required: true
          schema: { type: string, pattern: "^[a-z][a-z0-9-]{0,62}$" }
          example: generic
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/GenericWebhookEvent"
                - $ref: "#/components/schemas/AlertmanagerWebhook"
      responses:
        "202":
          description: Adapted, accepted, and durably recorded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventAccepted" }
        "400": { description: Malformed or invalid provider payload }
        "401": { description: Missing, conflicting, unknown, expired, revoked, or disabled integration key }
        "403": { description: Integration belongs to a suspended tenant }
        "404": { description: Adapter is not registered }
        "409": { description: Operation-bound idempotency conflict }
        "413": { description: Request exceeds 256 KiB }
        "415": { description: Adapter does not accept the supplied media type }
        "429": { description: Per-integration event limit or maximum active incidents exceeded }

  /v1/client-errors:
    post:
      summary: Report a privacy-bounded mobile client failure
      description: Accepts no message, stack, URL, token, user, incident content, or device identifier.
      operationId: reportMobileClientError
      security: [{ responderToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientErrorReport" }
      responses:
        "204": { description: Error envelope recorded }
        "400": { description: Invalid or unsafe envelope }
        "401": { description: Invalid responder token }
        "413": { description: Request body exceeds the endpoint limit }

  /v1/incidents:
    get:
      summary: List the caller's organization's incidents
      description: >
        Authenticated and org-scoped: returns only incidents whose immutable
        escalation snapshot includes the responder represented by the token.
      operationId: listIncidents
      security:
        - responderToken: []
      responses:
        "200":
          description: The organization's incidents, newest first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/IncidentSummary" }
        "401": { description: Missing or invalid responder token }

  /v1/me/devices:
    post:
      summary: Register or rotate this app installation's native push token
      operationId: registerDevice
      security:
        - responderToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeviceRegistration" }
      responses:
        "200":
          description: Device endpoint registered
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisteredDevice" }
        "400": { description: Invalid registration }
        "401": { description: Missing or invalid responder token }
        "413": { description: Request body exceeds the endpoint limit }
        "503": { description: Native push is not configured }

  /v1/me/status:
    get:
      summary: Get the signed-in responder's live operational status
      operationId: getResponderStatus
      security: [{ responderToken: [] }]
      responses:
        "200":
          description: Identity, live on-call assignments, upcoming handoffs, and incident counts
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResponderStatus" }
        "401": { description: Missing or invalid responder token }

  /v1/me/assistance-requests:
    get:
      summary: List the responder's pending or active incident assistance requests
      operationId: listAssistanceRequests
      security: [{ responderToken: [] }]
      responses:
        "200":
          description: Requests addressed to the signed-in responder
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AssistanceRequest" }
        "401": { description: Missing or invalid responder token }

  /v1/me/assistance-targets:
    get:
      summary: List responders and on-call teams available to invite into an incident
      operationId: listAssistanceTargets
      security: [{ responderToken: [] }]
      responses:
        "200":
          description: Organization-scoped invitation targets
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssistanceTargets" }
        "401": { description: Missing or invalid responder token }

  /v1/incidents/{id}/assistance-requests:
    post:
      summary: Invite a responder or on-call team into an incident response
      description: Available only to an assigned responder or an active incident participant.
      operationId: createResponderAssistanceRequest
      security: [{ responderToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAssistanceRequest" }
      responses:
        "201":
          description: Invitation persisted and notification queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssistanceRequest" }
        "400": { description: Invalid target, role, message, or incident state }
        "401": { description: Missing or invalid responder token }
        "404": { description: Incident not found or unavailable to this responder }
        "409": { description: A pending invitation already exists for the target }

  /v1/assistance-requests/{id}:
    get:
      summary: Get an assistance invitation and the incident context needed to answer it
      operationId: getAssistanceRequest
      security: [{ responderToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Invitation and incident
          content:
            application/json:
              schema:
                type: object
                required: [request, incident]
                properties:
                  request: { $ref: "#/components/schemas/AssistanceRequest" }
                  incident: { $ref: "#/components/schemas/Incident" }
        "401": { description: Missing or invalid responder token }
        "404": { description: Request not addressed to this responder }

  /v1/assistance-requests/{id}/respond:
    post:
      summary: Accept now, commit to a future time, or decline an assistance invitation
      operationId: respondToAssistanceRequest
      security: [{ responderToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [accept, accept_later, decline] }
                reason: { type: string, maxLength: 500 }
                available_at: { type: string, format: date-time, description: Required only for accept_later; between one minute and 24 hours from now. }
      responses:
        "200":
          description: Recorded response
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssistanceRequest" }
        "400": { description: Invalid response or availability time }
        "401": { description: Missing or invalid responder token }
        "409": { description: Invitation is no longer pending }

  /v1/incidents/{id}/participants/me/leave:
    post:
      summary: Leave an incident response team while preserving the audit trail
      description: A responder who leaves may receive and accept a new invitation later.
      operationId: leaveIncidentResponse
      security: [{ responderToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
      responses:
        "204": { description: Participant left; timeline activity recorded }
        "400": { description: Invalid reason }
        "401": { description: Missing or invalid responder token }
        "409": { description: Responder is not currently participating }

  /v1/me/devices/{installationID}:
    delete:
      summary: Revoke this app installation and its push endpoints
      operationId: revokeDevice
      security:
        - responderToken: []
      parameters:
        - name: installationID
          in: path
          required: true
          schema: { type: string }
      responses:
        "204": { description: Device revoked }
        "401": { description: Missing or invalid responder token }
        "404": { description: Installation not found }

  /v1/schedules/{id}/on-call:
    get:
      summary: Resolve who is on call for a schedule
      operationId: getOnCall
      security:
        - responderToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: at
          in: query
          description: RFC 3339 instant; defaults to now.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: The resolved responder
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnCallResolution" }
        "400": { description: Invalid at timestamp }
        "401": { description: Missing or invalid responder token }
        "404": { description: Schedule not found in this organization }
        "409": { description: Schedule is disabled, empty, or has not begun }

  /v1/webhooks/twilio/message-status:
    post:
      summary: Reconcile an outbound Twilio message status
      description: Twilio-only form callback authenticated with X-Twilio-Signature.
      security: []
      parameters:
        - name: attempt_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: X-Twilio-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [AccountSid, MessageSid, MessageStatus]
              properties:
                AccountSid: { type: string }
                MessageSid: { type: string }
                MessageStatus: { type: string }
                ErrorCode: { type: string }
      responses:
        "204": { description: Callback accepted, including authenticated but currently unmodeled statuses }
        "400": { description: Malformed callback or malformed signed attempt ID }
        "403": { description: Invalid Twilio signature or account }
        "413": { description: Form body exceeds the endpoint limit }
        "409": { description: Callback conflicts with the recorded attempt }
        "500": { description: Reconciliation failed }

  /v1/incidents/{id}:
    get:
      summary: Get an incident with its timeline
      description: >
        Authenticated, org-scoped, and authorized: the caller must present a
        responder token, the incident must belong to the caller's organization,
        and the caller must be on the incident's escalation-plan snapshot.
        Returns 404 for a missing incident, one in another org, OR an
        unauthorized one — existence never leaks.
      operationId: getIncident
      security:
        - responderToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The incident
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Incident" }
        "401": { description: Missing or invalid responder token }
        "404": { description: No such incident, in another org, or not authorized }

  /v1/incidents/{id}/ack:
    post:
      summary: Acknowledge an incident (responder)
      description: >
        Authenticated + authorized (the responder must be on the incident's
        escalation-plan snapshot). The acknowledging identity is taken from the
        bearer token, never the request body. The ack is persisted durably to
        Postgres — that record is the source of truth that stops escalation. The
        Temporal signal is only an advisory wake-up; the workflow re-reads the
        durable state and never trusts signal data.
      operationId: acknowledgeIncident
      security:
        - responderToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Acknowledged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AckResult" }
        "401": { description: Missing or invalid responder token }
        "403": { description: Authenticated but not a responder for this incident }
        "404": { description: No such incident, or it belongs to another org }
        "409": { description: No active escalation for this incident }

  /v1/admin/snapshot:
    get:
      summary: Load the configured organization's control-plane state
      operationId: getAdminSnapshot
      security: [{ adminToken: [] }]
      responses:
        "200":
          description: Responders, schedules, policies, services, and recent incidents
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminSnapshot" }
        "401": { description: Invalid admin token }
        "404": { description: Admin API is disabled }

  /v1/admin/onboarding/complete:
    post:
      summary: Atomically create a tenant's first response path
      description: Creates one responder, schedule, escalation policy, service, and Events API integration. This operation is accepted only while the tenant has no existing response configuration. The integration key is returned once.
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [responder_name, responder_email, schedule_name, timezone, handoff_time, policy_name, service_name]
              properties:
                responder_name: { type: string }
                responder_email: { type: string, format: email }
                schedule_name: { type: string }
                timezone: { type: string, example: America/Los_Angeles }
                handoff_time: { type: string, example: "09:00" }
                policy_name: { type: string }
                service_name: { type: string }
      responses:
        "201":
          description: Workspace configured; integration_key is returned once
        "400": { description: Invalid setup input }
        "409": { description: Workspace already contains response configuration }
        "401": { description: Invalid admin token }
        "404": { description: Admin API is disabled }
  /v1/admin/client-errors:
    post:
      summary: Report a privacy-bounded web console failure
      operationId: reportWebClientError
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientErrorReport" }
      responses:
        "204": { description: Error envelope recorded }
        "400": { description: Invalid or unsafe envelope }
        "401": { description: Invalid admin token }
        "413": { description: Request body exceeds the endpoint limit }
  /v1/admin/users:
    post:
      summary: Create a responder
      operationId: createAdminUser
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name: { type: string }
                email: { type: string, format: email }
      responses:
        "201": { description: Responder created }
        "400": { description: Invalid input }
        "401": { description: Invalid admin token }
        "409": { description: Conflicting responder }
  /v1/admin/users/{id}:
    put:
      summary: Edit or enable/disable a responder
      operationId: updateAdminUser
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name, email, enabled]
              properties:
                name: { type: string }
                email: { type: string, format: email }
                enabled: { type: boolean }
      responses:
        "200": { description: Responder updated }
        "400": { description: Invalid input or ID }
        "409": { description: Responder is still used by a schedule or policy }
  /v1/admin/teams:
    post:
      summary: Create an incident response team with a primary on-call schedule
      operationId: createAdminTeam
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TeamInput" }
      responses:
        "201": { description: Team created }
        "400": { description: Invalid members, schedule, name, or slug }
        "409": { description: Team slug already exists }
  /v1/admin/teams/{id}:
    put:
      summary: Update team membership and primary on-call schedule
      operationId: updateAdminTeam
      security: [{ adminToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TeamInput" }
      responses:
        "200": { description: Team updated }
        "400": { description: Invalid team configuration }
    delete:
      summary: Delete a team that has never been used in an incident invitation
      operationId: deleteAdminTeam
      security: [{ adminToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "204": { description: Team deleted }
        "409": { description: Team has immutable incident invitation history; disable it instead }
  /v1/admin/schedules:
    post:
      summary: Create an on-call rotation
      operationId: createAdminSchedule
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSchedule" }
      responses:
        "201": { description: Schedule created }
        "400": { description: Invalid input }
        "401": { description: Invalid admin token }
        "409": { description: Conflicting schedule or cross-org reference }
  /v1/admin/schedules/{id}:
    put:
      summary: Replace an on-call rotation atomically
      operationId: updateAdminSchedule
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateSchedule" }
      responses:
        "200": { description: Schedule updated }
        "400": { description: Invalid input or ID }
        "409": { description: Disabling would break an enabled service, or the update conflicts }
    delete:
      summary: Delete an unused schedule
      operationId: deleteAdminSchedule
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Schedule deleted }
        "409": { description: Schedule is referenced by configuration or incident history }
  /v1/admin/schedule-overrides:
    post:
      summary: Add a temporary schedule override
      operationId: createAdminScheduleOverride
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [schedule_id, user_id, starts_at, ends_at]
              properties:
                schedule_id: { type: string, format: uuid }
                layer_id: { type: string, format: uuid, description: Optional layer scope; omit to replace all active layers. }
                user_id: { type: string, format: uuid }
                starts_at: { type: string, format: date-time }
                ends_at: { type: string, format: date-time }
      responses:
        "204": { description: Override created }
        "400": { description: Invalid input }
        "401": { description: Invalid admin token }
        "409": { description: Conflicting or cross-org reference }
  /v1/admin/schedule-overrides/{id}:
    delete:
      summary: Remove a schedule override
      operationId: deleteAdminScheduleOverride
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Override removed }
        "400": { description: Invalid or unknown override }
  /v1/admin/policies:
    post:
      summary: Create an escalation policy and ordered steps
      operationId: createAdminPolicy
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, steps]
              properties:
                name: { type: string }
                steps:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/PolicyStep" }
      responses:
        "201": { description: Policy created }
        "400": { description: Invalid input }
        "401": { description: Invalid admin token }
        "409": { description: Conflicting or cross-org target }
  /v1/admin/policies/{id}:
    put:
      summary: Atomically replace an ordered escalation policy
      operationId: updateAdminPolicy
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdatePolicy" }
      responses:
        "200": { description: Policy updated }
        "400": { description: Invalid steps, target, or ID }
        "409": { description: Disabling would break an enabled service }
    delete:
      summary: Delete an unused escalation policy
      operationId: deleteAdminPolicy
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Policy deleted }
        "409": { description: Policy is still referenced by a service }
  /v1/admin/maintenance-windows:
    post:
      summary: Schedule a tenant-wide or service maintenance window
      security: [{ AdminBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MaintenanceWindowInput" }
      responses:
        "201": { description: Window scheduled, content: { application/json: { schema: { $ref: "#/components/schemas/MaintenanceWindow" } } } }
        "400": { description: Invalid window }
  /v1/admin/maintenance-windows/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      summary: Edit a scheduled maintenance window
      security: [{ AdminBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MaintenanceWindowInput" }
      responses:
        "200": { description: Window updated, content: { application/json: { schema: { $ref: "#/components/schemas/MaintenanceWindow" } } } }
    delete:
      summary: Cancel a maintenance window and release suppressed paging
      security: [{ AdminBearer: [] }]
      responses:
        "204": { description: Window canceled }

  /v1/admin/services:
    post:
      summary: Create a service and Events API integration
      description: Returns a service-scoped, cryptographically random integration key once.
      operationId: createAdminService
      security: [{ adminToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, policy_id]
              properties:
                name: { type: string }
                policy_id: { type: string, format: uuid }
      responses:
        "201": { description: Service created; integration key is returned once }
        "400": { description: Invalid input }
        "401": { description: Invalid admin token }
        "409": { description: Conflicting or cross-org policy }
  /v1/admin/services/{id}:
    put:
      summary: Edit or enable/disable a service and its integration
      operationId: updateAdminService
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name, policy_id, enabled]
              properties:
                name: { type: string }
                policy_id: { type: string, format: uuid }
                enabled: { type: boolean }
      responses:
        "200": { description: Service updated }
        "400": { description: Invalid or disabled policy, or invalid ID }
    delete:
      summary: Delete a service with no event or incident history
      operationId: deleteAdminService
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Service deleted }
        "409": { description: Service has event or incident history; disable it instead }
  /v1/admin/services/{id}/test-event:
    post:
      summary: Trigger a real test incident for a service
      description: Uses the service's Events API integration and the production ingestion, outbox, and escalation paths.
      operationId: triggerAdminTestEvent
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [summary]
              properties:
                summary: { type: string, minLength: 1, maxLength: 1024 }
      responses:
        "202":
          description: Test incident accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventAccepted" }
        "400": { description: Invalid service ID or event }
        "401": { description: Invalid admin token }
        "404": { description: Service or integration not found in this organization }
        "409": { description: Integration unavailable }
        "429": { description: Trigger limit exceeded }

  /v1/admin/event-adapters/{adapter}/preview:
    post:
      summary: Preview how a provider webhook payload maps to a canonical event
      description: >
        Runs the named adapter over the embedded provider payload and returns
        the mapped canonical event without ingesting anything. Deterministic
        and side-effect free. A payload the adapter rejects still returns 200
        with valid=false and the adapter's reason, so the console can show the
        diagnosis. Registered adapter names are listed in the admin snapshot's
        event_adapters field.
      operationId: previewAdminAdapterEvent
      security: [{ adminToken: [] }]
      parameters:
        - name: adapter
          in: path
          required: true
          schema: { type: string, pattern: "^[a-z][a-z0-9-]{0,62}$" }
          example: alertmanager
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdapterConsoleRequest" }
      responses:
        "200":
          description: Mapping preview (valid or diagnosed rejection)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdapterPreviewResult" }
        "400": { description: Missing or malformed console envelope }
        "401": { description: Invalid admin token }
        "404": { description: Adapter is not registered }

  /v1/admin/services/{id}/event-adapters/{adapter}/test:
    post:
      summary: Send a provider webhook payload through an adapter as a real test event
      description: >
        Decodes the embedded provider payload with the named adapter and
        ingests the result for the service's Events API integration through
        the production validation, idempotency, dedup, rate-limit, and
        escalation paths. A trigger creates a real incident and pages the
        on-call responder. The mapped dedup_key and client_event_id are
        rewritten into a deterministic console-test namespace, so a pasted
        production payload can never touch a real provider-correlated
        incident, while re-sending an identical payload still replays and a
        trigger/resolve sample pair still correlates. Suspended organizations
        are refused (service reads as not found).
      operationId: testAdminAdapterEvent
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: adapter
          in: path
          required: true
          schema: { type: string, pattern: "^[a-z][a-z0-9-]{0,62}$" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdapterConsoleRequest" }
      responses:
        "202":
          description: Adapted test event accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventAccepted" }
        "400": { description: Invalid service ID, console envelope, or provider payload }
        "401": { description: Invalid admin token }
        "404": { description: Service, integration, or adapter not found }
        "409": { description: Integration unavailable or event id conflict }
        "429": { description: Trigger limit exceeded }

  /v1/admin/services/{id}/integration-keys:
    post:
      summary: Create an additional integration key for zero-downtime rotation
      operationId: createAdminIntegrationKey
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IntegrationKeyParams" }
      responses:
        "201":
          description: Integration key created and returned once
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedIntegrationKey" }
        "400": { description: Invalid service, expiry, warning, or rate-limit configuration }

  /v1/admin/services/{id}/integration-keys/{keyID}/rotate:
    post:
      summary: Create a replacement while leaving the previous key active
      description: Update the sender and verify use of the replacement before revoking the previous key.
      operationId: rotateAdminIntegrationKey
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: keyID
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IntegrationKeyParams" }
      responses:
        "201":
          description: Replacement key created and returned once; previous key remains active
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedIntegrationKey" }
        "400": { description: Invalid service, key, or configuration }

  /v1/admin/services/{id}/integration-keys/{keyID}:
    delete:
      summary: Revoke an integration key immediately
      operationId: revokeAdminIntegrationKey
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: keyID
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Key revoked }
        "409": { description: An enabled service must retain one active integration key }
  /v1/admin/incidents/{id}:
    get:
      summary: Get the live operational view of an incident
      operationId: getAdminIncident
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Incident, immutable escalation plan, delivery attempts, and timeline
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminIncident" }
        "400": { description: Invalid incident ID }
        "401": { description: Invalid admin token }
        "404": { description: Incident not found in this organization }
  /v1/admin/incidents/{id}/assistance-requests:
    post:
      summary: Invite a responder to join an active incident response team
      operationId: createAdminAssistanceRequest
      security: [{ adminToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role, message]
              oneOf:
                - required: [target_user_id]
                - required: [target_team_id]
              properties:
                target_user_id: { type: string, format: uuid }
                target_team_id: { type: string, format: uuid }
                role: { type: string, enum: [incident_commander, primary_responder, subject_matter_expert, communications_lead, observer] }
                message: { type: string, minLength: 1, maxLength: 1000 }
      responses:
        "201": { description: Durable invitation and push handoff queued, content: { application/json: { schema: { $ref: "#/components/schemas/AssistanceRequest" } } } }
        "400": { description: Invalid target, role, message, or resolved incident }
        "409": { description: A pending invitation already exists for this responder and incident }
  /v1/admin/incidents/{id}/ack:
    post:
      summary: Acknowledge an incident as an administrator
      operationId: acknowledgeAdminIncident
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "202": { description: Durable acknowledgement recorded and workflow notified }
        "400": { description: Invalid incident ID }
        "401": { description: Invalid admin token }
        "404": { description: Incident not found in this organization }
        "409": { description: Incident is already resolved }
  /v1/admin/incidents/{id}/resolve:
    post:
      summary: Resolve an incident as an administrator
      description: Routes through the canonical source lifecycle and cancels escalation only after a durable state transition.
      operationId: resolveAdminIncident
      security: [{ adminToken: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: Incident was already resolved }
        "202": { description: Resolution accepted }
        "400": { description: Invalid incident ID }
        "401": { description: Invalid admin token }
        "404": { description: Incident not found in this organization }

  /v1/platform/me:
    get:
      summary: Return the authenticated global platform identity
      operationId: getPlatformIdentity
      security: [{ platformToken: [] }]
      responses:
        "200": { description: Platform identity, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformPrincipal" } } } }
        "401": { description: Invalid, expired, revoked, or non-platform credential }
  /v1/platform/overview:
    get:
      summary: Return the cross-tenant platform operations snapshot
      description: Returns privacy-safe aggregate counts, per-tenant workload, and the 50 latest immutable incident timeline entries. Raw event payloads, credentials, notification destinations, and provider responses are excluded.
      operationId: getPlatformOverview
      security: [{ platformToken: [] }]
      responses:
        "200": { description: Current platform operations snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformOverview" } } } }
        "401": { description: Invalid platform credential }
  /v1/platform/audit:
    get:
      summary: Recent platform-wide security audit events
      operationId: listPlatformAudit
      security: [{ platformToken: [] }]
      responses:
        "200": { description: Audit events, newest first, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/AuditEvent" } } } } }
        "401": { description: Invalid platform credential }
  /v1/platform/tenants:
    get:
      summary: List every tenant with operational counts
      operationId: listPlatformTenants
      security: [{ platformToken: [] }]
      responses:
        "200": { description: Tenant directory, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/PlatformTenant" } } } } }
        "401": { description: Invalid platform credential }
    post:
      summary: Create a tenant
      description: Requires platform_admin; platform_readonly receives 403.
      operationId: createPlatformTenant
      security: [{ platformToken: [] }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformTenantInput" } } } }
      responses:
        "201": { description: Tenant created, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformTenant" } } } }
        "403": { description: platform_readonly cannot mutate state }
        "409": { description: Tenant slug already exists }
  /v1/platform/tenants/{tenantID}:
    put:
      summary: Update a tenant's name, slug, and plan
      description: >
        Edits descriptive fields and plan only. The submitted status must match
        the tenant's current status; this endpoint never changes lifecycle
        status. Use /suspend and /reactivate for status transitions, which also
        stop escalation workflows and revoke sessions.
      operationId: updatePlatformTenant
      security: [{ platformToken: [] }]
      parameters: [{ name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformTenantInput" } } } }
      responses:
        "200": { description: Tenant updated, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformTenant" } } } }
        "400": { description: Invalid input, or the submitted status does not match the tenant's current status }
        "403": { description: platform_readonly cannot mutate state }
    delete:
      summary: Permanently delete a tenant and all tenant-owned data
      description: Requires platform_admin. The operation first suspends ingestion, resolves active incidents and alerts, neutralizes pending handoffs, terminates active escalation workflows, and then cascades deletion through all tenant-scoped records. This cannot be undone.
      operationId: deletePlatformTenant
      security: [{ platformToken: [] }]
      parameters: [{ name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "204": { description: Tenant and all tenant-owned records deleted }
        "400": { description: Invalid or unknown tenant }
        "403": { description: platform_readonly cannot mutate state }
  /v1/platform/tenants/{tenantID}/suspend:
    post:
      summary: Suspend a tenant
      description: >
        Transitions the tenant to suspended and makes the suspension safe in one
        step: resolves every active incident and alert (halting escalation),
        drains their pending notification outbox, revokes every console session
        token for the tenant's members, and terminates the active escalation
        workflows. Suspended tenants' events are rejected with 403
        tenant_suspended and their members can no longer sign in. Idempotent:
        suspending an already-suspended tenant is a no-op.
      operationId: suspendPlatformTenant
      security: [{ platformToken: [] }]
      parameters: [{ name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "200": { description: Tenant suspended, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformTenant" } } } }
        "403": { description: platform_readonly cannot mutate state }
        "404": { description: Tenant not found }
  /v1/platform/tenants/{tenantID}/reactivate:
    post:
      summary: Reactivate a suspended tenant
      description: >
        Returns a suspended tenant to active so it can send events and its
        members can sign in again. Incidents resolved during suspension stay
        resolved and sessions revoked during suspension stay revoked, so members
        obtain fresh tokens. Idempotent: reactivating an already-active tenant is
        a no-op. Use this for billing recovery.
      operationId: reactivatePlatformTenant
      security: [{ platformToken: [] }]
      parameters: [{ name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "200": { description: Tenant reactivated, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformTenant" } } } }
        "403": { description: platform_readonly cannot mutate state }
        "404": { description: Tenant not found }
  /v1/platform/users:
    get:
      summary: List global platform users
      operationId: listPlatformUsers
      security: [{ platformToken: [] }]
      responses:
        "200": { description: Global platform users, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/PlatformUser" } } } } }
    post:
      summary: Create a platform_admin or platform_readonly identity
      operationId: createPlatformUser
      security: [{ platformToken: [] }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformUserInput" } } } }
      responses:
        "201": { description: Platform user created, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformUser" } } } }
        "403": { description: platform_readonly cannot mutate state }
  /v1/platform/users/{id}:
    put:
      summary: Update a global platform identity or role
      operationId: updatePlatformUser
      security: [{ platformToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformUserUpdateInput" } } } }
      responses:
        "200": { description: Platform user updated, content: { application/json: { schema: { $ref: "#/components/schemas/PlatformUser" } } } }
        "403": { description: platform_readonly cannot mutate state }
        "409": { description: Update would remove the final enabled platform_admin }
    delete:
      summary: Permanently delete a global platform identity and all of its tokens
      description: Requires another enabled platform_admin when deleting an enabled platform_admin.
      operationId: deletePlatformUser
      security: [{ platformToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "204": { description: Platform user and tokens deleted }
        "403": { description: platform_readonly cannot mutate state }
        "409": { description: User is the final enabled platform_admin }
  /v1/platform/users/{id}/tokens:
    post:
      summary: Issue a random platform bearer token, returned once
      operationId: issuePlatformToken
      security: [{ platformToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "201": { description: Raw token shown once, content: { application/json: { schema: { $ref: "#/components/schemas/CreatedConsoleToken" } } } }
        "403": { description: platform_readonly cannot mutate state }
    delete:
      summary: Revoke all active tokens for a platform user
      operationId: revokePlatformTokens
      security: [{ platformToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "204": { description: Tokens revoked }
        "403": { description: platform_readonly cannot mutate state }
  /v1/platform/tenants/{tenantID}/members:
    get:
      summary: List tenant console identities and roles
      operationId: listPlatformTenantMembers
      security: [{ platformToken: [] }]
      parameters: [{ name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "200": { description: Tenant console identities, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/TenantMember" } } } } }
    post:
      summary: Create a tenant console identity
      operationId: createPlatformTenantMember
      security: [{ platformToken: [] }]
      parameters: [{ name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/TenantMemberInput" } } } }
      responses:
        "201": { description: Tenant console identity created }
        "403": { description: platform_readonly cannot mutate state }
  /v1/platform/tenants/{tenantID}/members/{id}:
    put:
      summary: Update a tenant console identity or role
      operationId: updatePlatformTenantMember
      security: [{ platformToken: [] }]
      parameters:
        - { name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/TenantMemberInput" } } } }
      responses:
        "200": { description: Tenant console identity updated }
        "403": { description: platform_readonly cannot mutate state }
        "409": { description: Update would remove the final enabled tenant_owner }
  /v1/platform/tenants/{tenantID}/members/{id}/tokens:
    post:
      summary: Issue a 90-day tenant console token, returned once
      operationId: issuePlatformTenantMemberToken
      security: [{ platformToken: [] }]
      parameters:
        - { name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "201": { description: Raw tenant token shown once, content: { application/json: { schema: { $ref: "#/components/schemas/CreatedConsoleToken" } } } }
        "403": { description: platform_readonly cannot mutate state }
    delete:
      summary: Revoke every active token for a tenant console identity
      operationId: revokePlatformTenantMemberTokens
      security: [{ platformToken: [] }]
      parameters:
        - { name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "204": { description: Tokens revoked }
        "403": { description: platform_readonly cannot mutate state }
  /v1/platform/tenants/{tenantID}/config/snapshot:
    get:
      summary: Inspect one tenant's complete configuration and readiness
      description: The remaining /config routes mirror /v1/admin for platform-authorized tenant management; platform_readonly is rejected on all non-read methods before dispatch.
      operationId: getPlatformTenantSnapshot
      security: [{ platformToken: [] }]
      parameters: [{ name: tenantID, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "200": { description: Tenant configuration, content: { application/json: { schema: { $ref: "#/components/schemas/AdminSnapshot" } } } }
        "404": { description: Tenant not found }

  /v1/tenant/me:
    get:
      summary: Return the authenticated tenant identity and organization
      operationId: getTenantIdentity
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Tenant identity (includes org_status; reachable by a suspended billing-only owner), content: { application/json: { schema: { $ref: "#/components/schemas/TenantPrincipal" } } } }
        "401": { description: Invalid, expired, revoked, disabled, or suspended-tenant credential }
  /v1/tenant/sessions:
    get:
      summary: List the caller's active console sessions
      operationId: listConsoleSessions
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Active sessions (the caller's is flagged current), content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/ConsoleSession" } } } } }
  /v1/tenant/sessions/{id}:
    delete:
      summary: Revoke one of the caller's own sessions
      operationId: revokeConsoleSession
      security: [{ tenantToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "204": { description: Session revoked }
        "404": { description: No such session for this user }
  /v1/tenant/sessions/revoke-others:
    post:
      summary: Revoke all of the caller's sessions except the current one
      operationId: revokeOtherConsoleSessions
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Count revoked, content: { application/json: { schema: { type: object, properties: { revoked: { type: integer } } } } } }
  /v1/tenant/audit:
    get:
      summary: Recent tenant security audit events (tenant_owner or tenant_admin)
      operationId: listTenantAudit
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Audit events, newest first, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/AuditEvent" } } } } }
        "403": { description: tenant_owner or tenant_admin required }
  /v1/tenant/billing:
    get:
      summary: Current subscription and trial state for the tenant
      description: >
        Reachable by any tenant role, and by a suspended tenant_owner (billing
        recovery). Reports plan, subscription_status, trial/period end, and
        org_status so the console can render a billing wall for a suspended
        tenant. billing_enabled reflects whether checkout is configured.
      operationId: getTenantBilling
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Billing state, content: { application/json: { schema: { $ref: "#/components/schemas/BillingState" } } } }
        "401": { description: Invalid tenant credential }
  /v1/tenant/billing/checkout:
    post:
      summary: Start a Stripe checkout session for a plan
      description: >
        tenant_owner only. Creates (or reuses) the tenant's Stripe customer and
        returns a hosted checkout URL for the chosen plan. Available to a
        suspended owner so they can recover. 404 when billing is disabled.
      operationId: startBillingCheckout
      security: [{ tenantToken: [] }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [plan], properties: { plan: { type: string, enum: [team, business, enterprise] } } } } } }
      responses:
        "200": { description: Hosted checkout URL, content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } } }
        "400": { description: Unknown or unpriced plan }
        "403": { description: tenant_owner role required }
        "404": { description: Billing is not enabled }
        "502": { description: Billing provider unavailable }
  /v1/tenant/billing/portal:
    post:
      summary: Open the Stripe customer billing portal
      description: >
        tenant_owner only. Returns a hosted portal URL where the customer manages
        payment methods, invoices, and cancellation. Available to a suspended
        owner. 409 if the tenant has no Stripe customer yet; 404 when disabled.
      operationId: openBillingPortal
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Hosted portal URL, content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } } }
        "403": { description: tenant_owner role required }
        "404": { description: Billing is not enabled }
        "409": { description: No Stripe customer for this tenant yet }
        "502": { description: Billing provider unavailable }
  /v1/webhooks/stripe:
    post:
      summary: Stripe subscription lifecycle webhook
      description: >
        Verifies the Stripe-Signature over the exact raw body, dedupes by event
        id, and records subscription state (status, plan, period/trial end). The
        billing worker turns that state into suspend/reactivate actions. 404 when
        billing is disabled.
      operationId: stripeWebhook
      responses:
        "200": { description: Accepted (or already processed) }
        "400": { description: Invalid or missing signature }
        "404": { description: Billing is not enabled }
  /v1/tenant/members:
    get:
      summary: List console identities in the credential's organization
      operationId: listTenantMembers
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Tenant identities, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/TenantMember" } } } } }
    post:
      summary: Create a tenant console identity
      description: Requires tenant_owner.
      operationId: createTenantMember
      security: [{ tenantToken: [] }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/TenantMemberInput" } } } }
      responses:
        "201": { description: Tenant identity created }
        "403": { description: tenant_owner role required }
  /v1/tenant/members/{id}:
    put:
      summary: Update a tenant console identity or role
      description: Requires tenant_owner and always scopes the target to the credential's organization.
      operationId: updateTenantMember
      security: [{ tenantToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/TenantMemberInput" } } } }
      responses:
        "200": { description: Tenant identity updated }
        "403": { description: tenant_owner role required }
        "409": { description: Update would remove the final enabled tenant_owner }
  /v1/tenant/members/{id}/tokens:
    post:
      summary: Issue a 90-day tenant console token, returned once
      description: Requires tenant_owner.
      operationId: issueTenantMemberToken
      security: [{ tenantToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "201": { description: Raw tenant token shown once, content: { application/json: { schema: { $ref: "#/components/schemas/CreatedConsoleToken" } } } }
        "403": { description: tenant_owner role required }
    delete:
      summary: Revoke every active token for a tenant identity
      description: Requires tenant_owner.
      operationId: revokeTenantMemberTokens
      security: [{ tenantToken: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        "204": { description: Tokens revoked }
        "403": { description: tenant_owner role required }
  /v1/tenant/config/snapshot:
    get:
      summary: Read the authenticated tenant's configuration and readiness
      description: The remaining /config routes mirror /v1/admin. tenant_viewer is read-only; tenant_admin and tenant_owner may mutate configuration.
      operationId: getTenantSnapshot
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Tenant configuration, content: { application/json: { schema: { $ref: "#/components/schemas/AdminSnapshot" } } } }

  /v1/tenant/config/users/{id}/responder-tokens:
    post:
      summary: Issue (or rotate) a responder access token for one responder
      description: >
        tenant_admin/tenant_owner only. Creates a high-entropy bearer credential for a responder in the
        caller's org; the plaintext is returned exactly once and stored only as a SHA-256 digest. With
        rotate=true, the responder's currently active tokens are revoked in the same transaction. Also
        available at /v1/admin and /v1/platform/tenants/{tid}/config.
      operationId: issueResponderToken
      security: [{ tenantToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                expires_in_days: { type: integer, minimum: 1, maximum: 365, default: 90 }
                rotate: { type: boolean, default: false }
      responses:
        "201":
          description: Token issued (plaintext shown once)
          content:
            application/json:
              schema:
                type: object
                required: [id, token, prefix, expires_at]
                properties:
                  id: { type: string, format: uuid }
                  token: { type: string, description: The plaintext bearer token — shown only here. }
                  prefix: { type: string }
                  expires_at: { type: string, format: date-time }
        "400": { description: Unknown responder, responder outside the org, or invalid TTL }
        "403": { description: tenant_viewer cannot issue tokens }
  /v1/tenant/config/responder-tokens/{id}:
    delete:
      summary: Revoke a responder access token
      description: tenant_admin/tenant_owner only. Revokes a token belonging to a responder in the caller's org.
      operationId: revokeResponderToken
      security: [{ tenantToken: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "204": { description: Token revoked }
        "403": { description: tenant_viewer cannot revoke tokens }
        "404": { description: No such active token in this org }
  /v1/tenant/integrations/slack:
    get:
      summary: Read the tenant's Slack installation and explicit mappings
      operationId: getTenantSlackConfiguration
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Slack configuration without credentials, content: { application/json: { schema: { $ref: "#/components/schemas/SlackConfiguration" } } } }
        "404": { description: Slack is disabled for this deployment }
  /v1/tenant/integrations/slack/oauth/start:
    post:
      summary: Create a single-use, tenant-bound Slack OAuth authorization URL
      description: Requires tenant_admin or tenant_owner. State expires after ten minutes and is consumed exactly once.
      operationId: startTenantSlackOAuth
      security: [{ tenantToken: [] }]
      responses:
        "201":
          description: Authorization URL created
          content: { application/json: { schema: { type: object, required: [authorize_url], properties: { authorize_url: { type: string, format: uri } } } } }
        "403": { description: tenant_viewer cannot install integrations }
  /v1/tenant/integrations/slack/user-links/{target}:
    put:
      summary: Explicitly bind one Slack member to one enabled WarnFire responder
      operationId: putTenantSlackUserLink
      security: [{ tenantToken: [] }]
      parameters: [{ name: target, in: path, required: true, description: Slack member ID to create or update., schema: { type: string } }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, additionalProperties: false, required: [responder_id], properties: { responder_id: { type: string, format: uuid }, slack_name: { type: string } } } } }
      responses:
        "200": { description: Identity linked }
        "400": { description: Invalid or cross-tenant mapping }
        "403": { description: tenant_viewer cannot modify integrations }
    delete:
      summary: Remove a Slack responder mapping
      operationId: deleteTenantSlackUserLink
      security: [{ tenantToken: [] }]
      parameters: [{ name: target, in: path, required: true, description: Existing mapping UUID to delete., schema: { type: string, format: uuid } }]
      responses:
        "204": { description: Mapping removed }
  /v1/tenant/integrations/slack/service-channels/{target}:
    put:
      summary: Route new incidents for a service to one Slack channel
      operationId: putTenantSlackServiceChannel
      security: [{ tenantToken: [] }]
      parameters: [{ name: target, in: path, required: true, description: Service UUID to create or update., schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, additionalProperties: false, required: [channel_id, enabled], properties: { channel_id: { type: string }, channel_name: { type: string }, enabled: { type: boolean } } } } }
      responses:
        "200": { description: Channel routing saved }
        "400": { description: Invalid or cross-tenant mapping }
    delete:
      summary: Remove Slack routing for a service
      operationId: deleteTenantSlackServiceChannel
      security: [{ tenantToken: [] }]
      parameters: [{ name: target, in: path, required: true, description: Existing mapping UUID to delete., schema: { type: string, format: uuid } }]
      responses:
        "204": { description: Mapping removed }
  /v1/tenant/integrations/opsgenie/:
    get:
      summary: Read the tenant's Opsgenie migration connection and latest review draft
      description: Returns credential metadata only; the encrypted API key is never exposed.
      operationId: getTenantOpsgenieMigration
      security: [{ tenantToken: [] }]
      responses:
        "200": { description: Migration state, content: { application/json: { schema: { $ref: "#/components/schemas/OpsgenieConfiguration" } } } }
        "404": { description: Provider credential encryption is disabled for this deployment }
  /v1/tenant/integrations/opsgenie/connection:
    put:
      summary: Validate and encrypt a read-only Opsgenie configuration credential
      description: Requires tenant_admin or tenant_owner. Only the fixed US and EU Opsgenie API hosts are allowed.
      operationId: putTenantOpsgenieConnection
      security: [{ tenantToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [region, api_key]
              properties:
                region: { type: string, enum: [us, eu] }
                api_key: { type: string, minLength: 1, maxLength: 8192, writeOnly: true }
      responses:
        "200": { description: Credential validated and encrypted, content: { application/json: { schema: { $ref: "#/components/schemas/OpsgenieConnection" } } } }
        "403": { description: tenant_viewer cannot modify integrations }
        "422": { description: Opsgenie rejected the credential or configuration-read access }
        "502": { description: Opsgenie could not be reached or returned an invalid response }
  /v1/tenant/integrations/opsgenie/imports:
    post:
      summary: Queue an immutable read-only Opsgenie inventory and compatibility assessment
      description: Requires tenant_admin or tenant_owner. This never changes live WarnFire configuration or writes to Opsgenie.
      operationId: createTenantOpsgenieImport
      security: [{ tenantToken: [] }]
      responses:
        "202": { description: Durable import queued, content: { application/json: { schema: { $ref: "#/components/schemas/OpsgenieImport" } } } }
        "403": { description: tenant_viewer cannot start imports }
        "409": { description: Another import is already queued or running }
  /v1/tenant/integrations/opsgenie/imports/{importID}/apply:
    post:
      summary: Activate a reviewed import — create live WarnFire configuration from the drafts
      description: >
        Requires tenant_admin or tenant_owner. Creates users, schedules, and escalation policies from the
        reviewed drafts in one transaction, reusing the same integrity checks as normal admin creation. Gated
        on the cutover report being ready (no dangling references) and runs at most once per import; services
        and non-translatable resources are reported as skipped for manual follow-up.
      operationId: applyTenantOpsgenieImport
      security: [{ tenantToken: [] }]
      parameters:
        - { name: importID, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Import applied, content: { application/json: { schema: { $ref: "#/components/schemas/OpsgenieApplyResult" } } } }
        "403": { description: tenant_viewer cannot apply imports }
        "409": { description: The import is not ready to apply or has already been applied }
  /v1/tenant/integrations/opsgenie/imports/{importID}/rollback:
    post:
      summary: Undo an applied import — delete exactly the configuration it created
      description: >
        Requires tenant_admin or tenant_owner. Deletes the users, schedules, escalation policies, and overrides
        the apply created, in one transaction, using the recorded ledger. Refused (deleting nothing) if any
        created resource is now in use by a service or an incident, or if newer configuration references it. On
        success the import returns to the reviewed-but-unapplied state so it can be applied again.
      operationId: rollbackTenantOpsgenieImport
      security: [{ tenantToken: [] }]
      parameters:
        - { name: importID, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Import rolled back, content: { application/json: { schema: { $ref: "#/components/schemas/OpsgenieRollbackResult" } } } }
        "403": { description: tenant_viewer cannot roll back imports }
        "409": { description: The import was not applied, or its resources are in use }
  /v1/integrations/slack/oauth/callback:
    get:
      summary: Complete a tenant-bound Slack OAuth installation
      security: []
      parameters:
        - { name: state, in: query, required: true, schema: { type: string } }
        - { name: code, in: query, required: false, schema: { type: string } }
        - { name: error, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: Workspace connected }
        "303": { description: Workspace connected; redirect to configured console URL }
        "400": { description: Invalid, expired, replayed, or declined authorization }
        "409": { description: Workspace is already bound to another tenant }
        "502": { description: Slack authorization exchange failed }
  /v1/integrations/slack/actions:
    post:
      summary: Process a signed Slack acknowledge or resolve action
      description: Signature covers the exact raw form body. Requests outside five minutes are rejected. The Slack member must be explicitly linked to a responder in the incident's immutable escalation snapshot.
      security: []
      parameters:
        - { name: X-Slack-Request-Timestamp, in: header, required: true, schema: { type: string } }
        - { name: X-Slack-Signature, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { type: object, required: [payload], properties: { payload: { type: string } } }
      responses:
        "200": { description: Ephemeral action result, including terminal authorization failures }
        "401": { description: Invalid signature or stale timestamp }
        "413": { description: Request body exceeds the endpoint limit }

components:
  securitySchemes:

    integrationKey:
      type: http
      scheme: bearer
      description: Service-scoped event integration key. X-WarnFire-Integration-Key is also accepted for webhook senders that cannot set Authorization.
    responderToken:
      type: http
      scheme: bearer
      description: A responder bearer token identifying who is acknowledging.
    adminToken:
      type: http
      scheme: bearer
      description: Strong environment-provided token for the single-org admin control plane.
    platformToken:
      type: http
      scheme: bearer
      description: Database-backed, SHA-256-digested credential for a global platform_admin or platform_readonly identity.
    tenantToken:
      type: http
      scheme: bearer
      description: Database-backed, SHA-256-digested credential bound to one active tenant and a tenant_owner, tenant_admin, or tenant_viewer role.
  schemas:
    CustomerSignup:
      type: object
      required: [name, email, password, organization_name, organization_slug]
      properties:
        name: { type: string, minLength: 2, maxLength: 120 }
        email: { type: string, format: email, maxLength: 254 }
        password: { type: string, format: password, minLength: 12, maxLength: 72 }
        organization_name: { type: string, minLength: 1, maxLength: 160 }
        organization_slug: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$" }

    CustomerAuthResult:
      type: object
      required: [principal]
      properties:
        token: { type: string, description: "Shown once; stored only as a digest. Empty when email_verification_required is true (verify before logging in)." }
        expires_at: { type: string, format: date-time }
        principal: { $ref: "#/components/schemas/TenantPrincipal" }
        email_verification_required: { type: boolean, description: True when signup withheld the session pending email verification. }

    ResponderAuthResult:
      type: object
      required: [token, expires_at, responder]
      properties:
        token: { type: string, description: Opaque mobile session stored in the device keychain; never entered by a person. }
        expires_at: { type: string, format: date-time }
        responder:
          type: object
          required: [id, name, email, org_id]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            email: { type: string, format: email }
            org_id: { type: string, format: uuid }

    ConsoleSession:
      type: object
      required: [id, created_at, current]
      properties:
        id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        user_agent: { type: string }
        ip: { type: string }
        current: { type: boolean, description: True for the session making this request. }

    AuditEvent:
      type: object
      required: [id, created_at, action]
      properties:
        id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        actor_email: { type: string }
        actor_role: { type: string }
        action: { type: string, example: login }
        target_type: { type: string }
        target_id: { type: string }
        detail: { type: object, additionalProperties: true }
        ip: { type: string }

    Health:
      type: object
      properties:
        status: { type: string, example: ok }
      required: [status]

    PlatformPrincipal:
      type: object
      required: [id, name, email, role]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [platform_admin, platform_readonly] }
    TenantPrincipal:
      type: object
      required: [id, name, email, role, org_id, org_name, org_slug, plan]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [tenant_owner, tenant_admin, tenant_viewer] }
        org_id: { type: string, format: uuid }
        org_name: { type: string }
        org_slug: { type: string }
        plan: { type: string, enum: [trial, team, business, enterprise] }
        org_status: { type: string, enum: [active, suspended] }
    BillingState:
      type: object
      required: [plan, subscription_status, cancel_at_period_end, has_subscription, org_status, billing_enabled]
      properties:
        plan: { type: string, enum: [trial, team, business, enterprise] }
        subscription_status:
          type: string
          enum: [trialing, active, past_due, canceled, unpaid, incomplete, incomplete_expired, paused, comped]
        trial_ends_at: { type: string, format: date-time }
        current_period_end: { type: string, format: date-time }
        cancel_at_period_end: { type: boolean }
        has_subscription: { type: boolean }
        org_status: { type: string, enum: [active, suspended] }
        billing_enabled: { type: boolean }
    CreatedConsoleToken:
      type: object
      required: [token, expires_at]
      properties:
        token: { type: string, description: 256-bit bearer credential returned only in this response. }
        expires_at: { type: string, format: date-time }
    PlatformUser:
      allOf:
        - $ref: "#/components/schemas/PlatformPrincipal"
        - type: object
          required: [enabled, created_at]
          properties:
            enabled: { type: boolean }
            created_at: { type: string, format: date-time }
    PlatformUserInput:
      type: object
      additionalProperties: false
      required: [name, email, role]
      properties:
        name: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [platform_admin, platform_readonly] }
        enabled: { type: boolean }
    PlatformUserUpdateInput:
      allOf:
        - $ref: "#/components/schemas/PlatformUserInput"
        - type: object
          required: [enabled]
    PlatformTenantInput:
      type: object
      additionalProperties: false
      required: [name, slug, status, plan]
      properties:
        name: { type: string }
        slug: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$" }
        status: { type: string, enum: [active, suspended] }
        plan: { type: string, enum: [trial, team, business, enterprise] }
    PlatformTenant:
      allOf:
        - $ref: "#/components/schemas/PlatformTenantInput"
        - type: object
          required: [id, responder_count, admin_count, service_count, active_incidents, created_at, updated_at]
          properties:
            id: { type: string, format: uuid }
            responder_count: { type: integer, minimum: 0 }
            admin_count: { type: integer, minimum: 0 }
            service_count: { type: integer, minimum: 0 }
            active_incidents: { type: integer, minimum: 0 }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }
    PlatformOverview:
      type: object
      required: [generated_at, status, metrics, delivery_health, tenants, activity]
      properties:
        generated_at: { type: string, format: date-time }
        status: { type: string, enum: [operational, attention] }
        metrics: { $ref: "#/components/schemas/PlatformOverviewMetrics" }
        delivery_health: { $ref: "#/components/schemas/PlatformDeliveryHealth" }
        tenants: { type: array, items: { $ref: "#/components/schemas/PlatformTenantStatus" } }
        activity: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/PlatformActivity" } }
    PlatformDeliveryHealth:
      type: object
      description: Structured delivery evidence for the operator UI. This projection does not modify the source logs and deliberately omits destinations, credentials, device tokens, and raw provider response bodies.
      required: [permanent_24h, retry_exhausted_24h, unknown_24h, provider_failed_24h, recent_failures]
      properties:
        permanent_24h: { type: integer, minimum: 0 }
        retry_exhausted_24h: { type: integer, minimum: 0 }
        unknown_24h: { type: integer, minimum: 0 }
        provider_failed_24h: { type: integer, minimum: 0 }
        recent_failures:
          type: array
          maxItems: 25
          items: { $ref: "#/components/schemas/PlatformDeliveryFailure" }
    PlatformDeliveryFailure:
      type: object
      required: [attempt_id, tenant_id, tenant_name, incident_id, incident_name, channel, status, provider_status, failure_code, failure_detail, at]
      properties:
        attempt_id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        tenant_name: { type: string }
        incident_id: { type: string, format: uuid }
        incident_name: { type: string }
        channel: { type: string }
        status: { type: string }
        provider_status: { type: string }
        failure_code: { type: string }
        failure_detail: { type: string, maxLength: 256 }
        at: { type: string, format: date-time }
    PlatformOverviewMetrics:
      type: object
      required: [total_tenants, active_tenants, suspended_tenants, enabled_responders, enabled_services, triggered_incidents, acknowledged_incidents, resolved_incidents_24h, events_24h, notifications_24h, failed_notifications_24h, stalled_workflows, failed_workflows_24h]
      properties:
        total_tenants: { type: integer, minimum: 0 }
        active_tenants: { type: integer, minimum: 0 }
        suspended_tenants: { type: integer, minimum: 0 }
        enabled_responders: { type: integer, minimum: 0 }
        enabled_services: { type: integer, minimum: 0 }
        triggered_incidents: { type: integer, minimum: 0 }
        acknowledged_incidents: { type: integer, minimum: 0 }
        resolved_incidents_24h: { type: integer, minimum: 0 }
        events_24h: { type: integer, minimum: 0 }
        notifications_24h: { type: integer, minimum: 0 }
        failed_notifications_24h: { type: integer, minimum: 0 }
        stalled_workflows: { type: integer, minimum: 0 }
        failed_workflows_24h: { type: integer, minimum: 0 }
    PlatformTenantStatus:
      type: object
      required: [id, name, slug, status, plan, enabled_responders, enabled_services, triggered_incidents, acknowledged_incidents, events_24h, failed_notifications_24h]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        status: { type: string, enum: [active, suspended] }
        plan: { type: string, enum: [trial, team, business, enterprise] }
        enabled_responders: { type: integer, minimum: 0 }
        enabled_services: { type: integer, minimum: 0 }
        triggered_incidents: { type: integer, minimum: 0 }
        acknowledged_incidents: { type: integer, minimum: 0 }
        events_24h: { type: integer, minimum: 0 }
        failed_notifications_24h: { type: integer, minimum: 0 }
        last_activity_at: { type: string, format: date-time }
    PlatformActivity:
      type: object
      required: [sequence, kind, at, tenant_id, tenant_name, service_id, service_name, incident_id, incident_name, incident_status]
      properties:
        sequence: { type: integer, format: int64 }
        kind: { type: string }
        at: { type: string, format: date-time }
        tenant_id: { type: string, format: uuid }
        tenant_name: { type: string }
        service_id: { type: string, format: uuid }
        service_name: { type: string }
        incident_id: { type: string, format: uuid }
        incident_name: { type: string }
        incident_status: { type: string, enum: [triggered, acknowledged, resolved] }
    TenantMemberInput:
      type: object
      additionalProperties: false
      required: [name, email, role, enabled]
      properties:
        name: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [tenant_owner, tenant_admin, tenant_viewer] }
        enabled: { type: boolean }
    TenantMember:
      allOf:
        - $ref: "#/components/schemas/TenantMemberInput"
        - type: object
          required: [id, org_id]
          properties:
            id: { type: string, format: uuid }
            org_id: { type: string, format: uuid }

    SlackConfiguration:
      type: object
      required: [user_links, channels]
      properties:
        installation:
          type: object
          required: [id, team_id, team_name, bot_user_id, enabled]
          properties:
            id: { type: string, format: uuid }
            team_id: { type: string }
            team_name: { type: string }
            bot_user_id: { type: string }
            enabled: { type: boolean }
        user_links:
          type: array
          items:
            type: object
            required: [id, slack_user_id, slack_name, responder_id, responder_name]
            properties:
              id: { type: string, format: uuid }
              slack_user_id: { type: string }
              slack_name: { type: string }
              responder_id: { type: string, format: uuid }
              responder_name: { type: string }
        channels:
          type: array
          items:
            type: object
            required: [id, service_id, service_name, channel_id, channel_name, enabled]
            properties:
              id: { type: string, format: uuid }
              service_id: { type: string, format: uuid }
              service_name: { type: string }
              channel_id: { type: string }
              channel_name: { type: string }
              enabled: { type: boolean }

    OpsgenieConnection:
      type: object
      required: [id, region, api_key_hint, enabled, last_validated_at, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        region: { type: string, enum: [us, eu] }
        api_key_hint: { type: string, description: Redacted display suffix only. }
        enabled: { type: boolean }
        last_validated_at: { type: string, format: date-time }
        last_import_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    OpsgenieImport:
      type: object
      required: [id, status, exact_count, approximate_count, ambiguous_count, unsupported_count, created_at]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [queued, running, succeeded, failed] }
        exact_count: { type: integer, minimum: 0 }
        approximate_count: { type: integer, minimum: 0 }
        ambiguous_count: { type: integer, minimum: 0 }
        unsupported_count: { type: integer, minimum: 0 }
        error_code: { type: string }
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        applied_at: { type: string, format: date-time, description: Set once the reviewed import has been applied to live configuration. }
    OpsgenieImportItem:
      type: object
      required: [id, resource_type, external_id, name, mapping_status, source, findings]
      properties:
        id: { type: string, format: uuid }
        resource_type: { type: string, enum: [user, team, schedule, override, escalation, service, integration] }
        external_id: { type: string }
        name: { type: string }
        mapping_status: { type: string, enum: [exact, approximate, ambiguous, unsupported] }
        source: { type: object, description: Sanitized immutable provider source; credential-shaped fields are redacted. }
        draft: { type: object, description: Review-only proposed WarnFire representation. }
        findings: { type: array, items: { type: string } }
    OpsgenieConfiguration:
      type: object
      required: [imports, items]
      properties:
        connection: { $ref: "#/components/schemas/OpsgenieConnection" }
        imports: { type: array, items: { $ref: "#/components/schemas/OpsgenieImport" } }
        items: { type: array, items: { $ref: "#/components/schemas/OpsgenieImportItem" } }
        report: { type: object, description: Cutover-readiness assessment of the latest import (go/no-go, totals, blockers, warnings). }
        shadow: { type: object, description: Schedule shadow run comparing the translated drafts against Opsgenie on-call over the coming week. }
        alert_shadow: { type: object, description: "Alert-stream shadow: a sample of recent real Opsgenie alerts replayed against the drafts, scoring whether WarnFire would page the responder Opsgenie actually engaged." }
    OpsgenieApplyResult:
      type: object
      required: [import_id, applied_at, created, skipped]
      properties:
        import_id: { type: string, format: uuid }
        applied_at: { type: string, format: date-time }
        created:
          type: object
          required: [users, schedules, escalations, overrides]
          properties:
            users: { type: integer, minimum: 0 }
            schedules: { type: integer, minimum: 0 }
            escalations: { type: integer, minimum: 0 }
            overrides: { type: integer, minimum: 0 }
        skipped:
          type: array
          items:
            type: object
            required: [resource_type, name, external_id, reason]
            properties:
              resource_type: { type: string }
              name: { type: string }
              external_id: { type: string }
              reason: { type: string }
    OpsgenieRollbackResult:
      type: object
      required: [import_id, removed]
      properties:
        import_id: { type: string, format: uuid }
        removed:
          type: object
          required: [users, schedules, escalations, overrides]
          properties:
            users: { type: integer, minimum: 0 }
            schedules: { type: integer, minimum: 0 }
            escalations: { type: integer, minimum: 0 }
            overrides: { type: integer, minimum: 0 }

    ClientErrorReport:
      type: object
      additionalProperties: false
      required: [client, operation, code]
      properties:
        client: { type: string, enum: [web, mobile] }
        operation: { type: string, pattern: "^[A-Za-z0-9_.:-]{1,64}$" }
        code: { type: string, pattern: "^[A-Za-z0-9_.:-]{1,64}$" }
        status: { type: integer, minimum: 0, maximum: 599 }
        release: { type: string, pattern: "^[A-Za-z0-9_.:-]{1,64}$" }
        correlation_id: { type: string, format: uuid }

    DeviceRegistration:
      type: object
      required: [installation_id, platform, provider, environment, token]
      properties:
        installation_id: { type: string, maxLength: 200 }
        platform: { type: string, enum: [ios, android] }
        provider: { type: string, enum: [apns, fcm] }
        environment: { type: string, enum: [development, production] }
        app_version: { type: string }
        locale: { type: string }
        token: { type: string, description: Native APNs/FCM token; encrypted before persistence. }

    RegisteredDevice:
      type: object
      required: [installation_id, endpoint_id]
      properties:
        installation_id: { type: string }
        endpoint_id: { type: string, format: uuid }

    OnCallResolution:
      type: object
      required: [schedule_id, user_id, at, override]
      properties:
        schedule_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        at: { type: string, format: date-time }
        override: { type: boolean }
        responders:
          type: array
          description: Every responder contributed by an active rotation layer, in layer order.
          items:
            type: object
            required: [user_id, override]
            properties:
              user_id: { type: string, format: uuid }
              layer_id: { type: string, format: uuid }
              layer_name: { type: string }
              override: { type: boolean }

    ResponderStatus:
      type: object
      required: [user_id, name, generated_at, is_on_call, responders_total, responders_active, schedules, stats]
      properties:
        user_id: { type: string, format: uuid }
        name: { type: string }
        generated_at: { type: string, format: date-time }
        is_on_call: { type: boolean }
        responders_total: { type: integer, minimum: 0 }
        responders_active: { type: integer, minimum: 0, description: Distinct enabled responders whose valid credential was used in the last five minutes. }
        schedules:
          type: array
          items:
            type: object
            required: [id, name, timezone, on_call_names, user_is_on_call, override_active]
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              timezone: { type: string }
              on_call_names: { type: array, items: { type: string } }
              user_is_on_call: { type: boolean }
              next_handoff_at: { type: string, format: date-time }
              override_active: { type: boolean }
        stats:
          type: object
          required: [triggered, acknowledged, resolved_24h, critical_open, escalation_exhausted_open, received_24h, average_ack_seconds_24h, average_resolve_seconds_24h]
          properties:
            triggered: { type: integer, minimum: 0 }
            acknowledged: { type: integer, minimum: 0 }
            resolved_24h: { type: integer, minimum: 0 }
            critical_open: { type: integer, minimum: 0 }
            escalation_exhausted_open: { type: integer, minimum: 0 }
            received_24h: { type: integer, minimum: 0 }
            oldest_open_at: { type: string, format: date-time }
            average_ack_seconds_24h: { type: number, minimum: 0 }
            average_resolve_seconds_24h: { type: number, minimum: 0 }

    ScheduleLayer:
      type: object
      additionalProperties: false
      required: [name, starts_at, shift_unit, shift_length, active_weekdays, member_user_ids]
      properties:
        id: { type: string, format: uuid, description: Preserve this value when updating an existing layer. }
        name: { type: string }
        position: { type: integer, minimum: 1 }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        shift_unit: { type: string, enum: [hours, days, weeks] }
        shift_length: { type: integer, minimum: 1 }
        active_weekdays:
          type: array
          minItems: 1
          uniqueItems: true
          items: { type: integer, minimum: 0, maximum: 6, description: Sunday is 0. }
        active_start_time: { type: string, example: "09:00:00" }
        active_end_time: { type: string, example: "17:00:00" }
        member_user_ids:
          type: array
          minItems: 1
          uniqueItems: true
          items: { type: string, format: uuid }

    CreateSchedule:
      type: object
      additionalProperties: false
      required: [name, timezone, layers]
      properties:
        name: { type: string }
        timezone: { type: string, example: America/Los_Angeles }
        layers:
          type: array
          minItems: 1
          maxItems: 20
          items: { $ref: "#/components/schemas/ScheduleLayer" }

    UpdateSchedule:
      allOf:
        - $ref: "#/components/schemas/CreateSchedule"
        - type: object
          required: [enabled]
          properties:
            enabled: { type: boolean }

    UpdatePolicy:
      type: object
      additionalProperties: false
      required: [name, enabled, steps]
      properties:
        name: { type: string }
        enabled: { type: boolean }
        steps:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/PolicyStep" }

    PolicyStep:
      type: object
      additionalProperties: false
      required: [delay_seconds, condition, targets]
      properties:
        position: { type: integer, minimum: 1 }
        delay_seconds: { type: integer, minimum: 0 }
        condition: { type: string, enum: [while_unacknowledged, while_unresolved] }
        targets:
          type: array
          minItems: 1
          maxItems: 20
          items:
            type: object
            additionalProperties: false
            oneOf:
              - required: [user_id]
              - required: [schedule_id]
            properties:
              user_id: { type: string, format: uuid }
              schedule_id: { type: string, format: uuid }

    IntegrationKeyParams:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, description: Human-readable sender or environment name. }
        expires_at: { type: [string, "null"], format: date-time }
        trigger_limit_per_minute: { type: integer, minimum: 0, maximum: 10000, description: Zero uses the deployment default. }
        expiry_warning_days: { type: integer, minimum: 0, maximum: 90, default: 7, description: Zero disables the warning. }
        unused_warning_days: { type: integer, minimum: 0, maximum: 365, default: 7, description: Zero disables the warning. }

    CreatedIntegrationKey:
      type: object
      required: [id, service_id, integration_key, display, name, created_at]
      properties:
        id: { type: string, format: uuid }
        service_id: { type: string, format: uuid }
        integration_key: { type: string, description: Secret returned by this response only. }
        display: { type: string, example: "wf_live_••••8K2P" }
        name: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    TeamInput:
      type: object
      additionalProperties: false
      required: [name, slug, primary_schedule_id, member_user_ids, enabled]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        slug: { type: string, pattern: "^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$" }
        description: { type: string, maxLength: 1000 }
        primary_schedule_id: { type: string, format: uuid }
        member_user_ids: { type: array, minItems: 1, uniqueItems: true, items: { type: string, format: uuid } }
        enabled: { type: boolean }

    Team:
      allOf:
        - { $ref: "#/components/schemas/TeamInput" }
        - type: object
          required: [id]
          properties:
            id: { type: string, format: uuid }

    AdminSnapshot:
      type: object
      required: [users, teams, schedules, overrides, policies, services, incidents, warnings, ready, ingest_protections]
      properties:
        users:
          type: array
          items:
            type: object
            required: [id, name, email, enabled]
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              email: { type: string, format: email }
              enabled: { type: boolean }
        teams:
          type: array
          items: { $ref: "#/components/schemas/Team" }
        schedules:
          type: array
          items:
            type: object
            description: Includes current on-call identity and a bounded 14-day coverage preview.
        event_adapters:
          type: array
          description: Registered provider webhook adapter names, for the console adapter tester.
          items: { type: string }
        responder_tokens:
          type: array
          items:
            type: object
            description: Responder bearer-credential lifecycle (secret never included; prefix + status only).
            properties:
              id: { type: string, format: uuid }
              user_id: { type: string, format: uuid }
              user_name: { type: string }
              prefix: { type: string }
              status: { type: string, enum: [active, expired, revoked] }
              created_at: { type: string, format: date-time }
              expires_at: { type: string, format: date-time }
              last_used_at: { type: string, format: date-time }
              rotated: { type: boolean }
        overrides: { type: array, items: { type: object } }
        policies: { type: array, items: { type: object } }
        services:
          type: array
          items:
            type: object
            description: Includes enabled state, integration_health, last_event_at, and redacted integration-key lifecycle metadata.
        ingest_protections:
          type: object
          required: [default_triggers_per_minute, max_active_incidents_per_service]
          properties:
            default_triggers_per_minute: { type: integer }
            max_active_incidents_per_service:
              type: integer
              description: Maximum number of incidents that may be active for one service. Existing incidents remain actionable after the limit is reached.
        incidents: { type: array, items: { $ref: "#/components/schemas/IncidentSummary" } }
        maintenance_windows:
          type: array
          items: { $ref: "#/components/schemas/MaintenanceWindow" }
        warnings:
          type: array
          items:
            type: object
            required: [code, severity, resource_type, message]
            properties:
              code: { type: string }
              severity: { type: string, enum: [warning, error] }
              resource_type: { type: string }
              resource_id: { type: string, format: uuid }
              message: { type: string }
        ready:
          type: boolean
          description: False when an enabled paging path is unusable.

    MaintenanceWindowInput:
      type: object
      required: [name, starts_at, ends_at, timezone, allow_critical_override]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        reason: { type: string, maxLength: 1000 }
        service_id: { type: string, format: uuid, description: Omit for all services in the tenant. }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        timezone: { type: string, example: America/Los_Angeles }
        allow_critical_override: { type: boolean, default: true }

    MaintenanceWindow:
      type: object
      required: [id, name, reason, starts_at, ends_at, timezone, allow_critical_override, status, created_by, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        reason: { type: string }
        service_id: { type: string, format: uuid }
        service_name: { type: string }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        timezone: { type: string, example: America/Los_Angeles }
        allow_critical_override: { type: boolean }
        status: { type: string, enum: [scheduled, active, completed, canceled] }
        created_by: { type: string }
        created_at: { type: string, format: date-time }

    EventIn:
      type: object
      required: [event_action]
      anyOf:
        - required: [integration_key]
        - required: [routing_key]
      properties:
        integration_key:
          type: string
          description: Service-scoped integration key that authenticates the event source.
        routing_key:
          type: string
          deprecated: true
          description: Deprecated compatibility alias for integration_key.
        event_action:
          type: string
          enum: [trigger, acknowledge, resolve]
        dedup_key:
          type: string
          description: >
            Correlates events for one active alert. Required for acknowledge /
            resolve; on trigger it is generated server-side if omitted.
        client_event_id:
          type: string
          description: Caller-supplied unique id for request idempotency. Distinct from dedup_key.
        payload:
          description: Required when event_action is trigger.
          $ref: "#/components/schemas/EventPayload"

    GenericWebhookEvent:
      type: object
      additionalProperties: false
      required: [event_action]
      description: Canonical event envelope without a body credential; the integration key is supplied in an HTTP header.
      properties:
        event_action:
          type: string
          enum: [trigger, acknowledge, resolve]
        dedup_key:
          type: string
          maxLength: 255
          description: Required for acknowledge and resolve; optional for trigger.
        client_event_id:
          type: string
          maxLength: 255
          description: Stable provider delivery identifier used for operation-bound idempotency.
        payload:
          description: Required when event_action is trigger.
          $ref: "#/components/schemas/EventPayload"

    AlertmanagerWebhook:
      type: object
      required: [version, groupKey, status, alerts]
      description: >
        Prometheus Alertmanager webhook notification, payload version 4.
        Unknown fields are tolerated because Alertmanager adds fields without
        bumping the version. The group's status drives the event action
        (firing → trigger, resolved → resolve); the dedup key derives from
        groupKey; the idempotency key is a hash of the canonical mapped event
        (action, dedup key, and payload with sorted collections), so provider
        retries replay safely while any visible update becomes a new event. A
        resolved group containing a firing member alert is rejected.
        The conventional commonLabels severity values critical/error/warning/
        warn/info are honored; anything else maps to error and the original
        value is preserved in the event labels.
      properties:
        version: { type: string, enum: ["4"] }
        groupKey: { type: string, minLength: 1 }
        truncatedAlerts: { type: integer, minimum: 0 }
        status: { type: string, enum: [firing, resolved] }
        receiver: { type: string }
        groupLabels: { type: object, additionalProperties: { type: string } }
        commonLabels: { type: object, additionalProperties: { type: string } }
        commonAnnotations: { type: object, additionalProperties: { type: string } }
        externalURL: { type: string }
        alerts:
          type: array
          description: May be empty only when truncatedAlerts is positive.
          items:
            type: object
            required: [status, fingerprint]
            properties:
              status: { type: string, enum: [firing, resolved] }
              labels: { type: object, additionalProperties: { type: string } }
              annotations: { type: object, additionalProperties: { type: string } }
              startsAt: { type: string, format: date-time }
              endsAt: { type: string, format: date-time }
              generatorURL: { type: string }
              fingerprint: { type: string, minLength: 1 }

    AdapterConsoleRequest:
      type: object
      additionalProperties: false
      required: [payload]
      description: Console envelope wrapping a raw provider webhook payload for preview or send-test.
      properties:
        payload:
          description: >
            The provider payload as a JSON value. The console parses and
            re-serializes pasted text, so adapters receive an equivalent JSON
            value rather than the provider's original raw bytes.
        content_type:
          type: string
          description: Media type the adapter should assume. Defaults to application/json.

    AdapterPreviewResult:
      type: object
      required: [adapter, valid]
      properties:
        adapter: { type: string }
        valid: { type: boolean }
        error:
          type: string
          description: Present when valid is false; the adapter's rejection reason.
        event:
          description: Present when valid is true; the mapped canonical event (credentials always empty).
          $ref: "#/components/schemas/GenericWebhookEvent"

    EventPayload:
      type: object
      required: [summary, source, severity]
      properties:
        summary: { type: string, minLength: 1, maxLength: 1024 }
        source: { type: string, minLength: 1, maxLength: 255, description: The affected host/service/component. }
        severity:
          type: string
          enum: [critical, error, warning, info]
        component: { type: string, maxLength: 255 }
        description: { type: string, maxLength: 8192 }
        environment: { type: string, maxLength: 255 }
        region: { type: string, maxLength: 255 }
        maintenance_override:
          type: boolean
          default: false
          description: Requests that a critical event bypass an active maintenance window. Honored only when severity is critical and the active window allows overrides.
        details:
          type: object
          maxProperties: 64
          propertyNames: { minLength: 1, maxLength: 128 }
          description: Flat typed measurements and facts. Null removes an existing key on a repeated trigger.
          additionalProperties:
            oneOf:
              - { type: string, maxLength: 1024 }
              - { type: number }
              - { type: boolean }
              - { type: "null" }
        labels:
          type: object
          maxProperties: 64
          propertyNames: { minLength: 1, maxLength: 128 }
          additionalProperties: { type: string, maxLength: 1024 }
        tags:
          type: array
          maxItems: 64
          uniqueItems: true
          items: { type: string, minLength: 1, maxLength: 128 }
        links:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/EventLink" }
        timestamp:
          type: string
          format: date-time
          description: When the source observed the event (RFC 3339).

    EventLink:
      type: object
      required: [href]
      properties:
        href: { type: string, format: uri, maxLength: 1024 }
        text: { type: string, maxLength: 255 }

    EventAccepted:
      type: object
      required: [status, dedup_key]
      properties:
        status: { type: string, example: accepted }
        dedup_key:
          type: string
          description: The correlation key for the active alert (echoed or generated).
        incident_id:
          type: string
          format: uuid
          description: Incident acted on, when one exists.

    IncidentSummary:
      type: object
      required: [id, status, summary, created_at]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [triggered, acknowledged, resolved]
        summary: { type: string }
        created_at: { type: string }
        resolved_at: { type: string, format: date-time }
        service_id: { type: string, format: uuid }
        service_name: { type: string }
        severity: { type: string, enum: [critical, error, warning, info] }
        environment: { type: string }
        region: { type: string }
        escalation_exhausted: { type: boolean }
        paging_suppressed_until: { type: string, format: date-time }

    AdminIncident:
      type: object
      required: [id, status, summary, service_id, service_name, dedup_key, created_at, current_step, escalation_exhausted, responders, attempts, assistance_requests, participants, timeline]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [triggered, acknowledged, resolved] }
        summary: { type: string }
        service_id: { type: string, format: uuid }
        service_name: { type: string }
        dedup_key: { type: string }
        created_at: { type: string, format: date-time }
        acked_at: { type: string, format: date-time }
        acked_by: { type: string }
        resolved_at: { type: string, format: date-time }
        current_step: { type: integer, minimum: 0 }
        escalation_exhausted: { type: boolean }
        paging_suppressed_until: { type: string, format: date-time }
        maintenance_override: { type: boolean }
        responders:
          type: array
          items:
            type: object
            required: [position, user_id, name, delay_seconds]
            properties:
              position: { type: integer, minimum: 1 }
              user_id: { type: string, format: uuid }
              name: { type: string }
              delay_seconds: { type: integer, minimum: 0 }
        attempts:
          type: array
          items:
            type: object
            required: [id, step_position, responder_name, channel, status, queued_at]
            properties:
              id: { type: string, format: uuid }
              step_position: { type: integer, minimum: 1 }
              responder_name: { type: string }
              channel: { type: string }
              status: { type: string }
              provider_status: { type: string }
              queued_at: { type: string, format: date-time }
              provider_accepted_at: { type: string, format: date-time }
              delivered_at: { type: string, format: date-time }
              failed_at: { type: string, format: date-time }
              failure_code: { type: string }
              failure_detail: { type: string, maxLength: 256 }
        assistance_requests:
          type: array
          items: { $ref: "#/components/schemas/AssistanceRequest" }
        participants:
          type: array
          items:
            type: object
            required: [user_id, name, role, status]
            properties:
              user_id: { type: string, format: uuid }
              name: { type: string }
              role: { type: string }
              status: { type: string, enum: [committed, active, left] }
              available_at: { type: string, format: date-time }
              joined_at: { type: string, format: date-time }
              left_at: { type: string, format: date-time }
        timeline:
          type: array
          items:
            type: object
            required: [seq, kind, data, at]
            properties:
              seq: { type: integer, format: int64 }
              kind: { type: string }
              data: { type: object }
              at: { type: string, format: date-time }

    Incident:
      type: object
      required: [id, status, timeline, update_count]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [triggered, acknowledged, resolved]
        summary: { type: string }
        source: { type: string }
        severity: { type: string, enum: [critical, error, warning, info] }
        component: { type: string }
        observed_at: { type: string, format: date-time }
        description: { type: string }
        environment: { type: string }
        region: { type: string }
        details:
          type: object
          additionalProperties:
            oneOf:
              - { type: string }
              - { type: number }
              - { type: boolean }
              - { type: "null" }
        labels: { type: object, additionalProperties: { type: string } }
        tags: { type: array, items: { type: string } }
        links: { type: array, items: { $ref: "#/components/schemas/EventLink" } }
        acked_by: { type: string }
        update_count: { type: integer, minimum: 0 }
        paging_suppressed_until: { type: string, format: date-time }
        maintenance_override: { type: boolean }
        timeline:
          type: array
          items: { $ref: "#/components/schemas/TimelineEntry" }

    AssistanceRequest:
      type: object
      required: [id, incident_id, incident_summary, target_user_id, target_name, requested_by_name, requested_role, message, status, expires_at, created_at]
      properties:
        id: { type: string, format: uuid }
        incident_id: { type: string, format: uuid }
        incident_summary: { type: string }
        target_user_id: { type: string, format: uuid }
        target_name: { type: string }
        target_team_id: { type: string, format: uuid }
        target_team_name: { type: string }
        requested_by_name: { type: string }
        requested_role: { type: string, enum: [incident_commander, primary_responder, subject_matter_expert, communications_lead, observer] }
        message: { type: string }
        status: { type: string, enum: [pending, accepted, accepted_delayed, declined, expired, canceled] }
        available_at: { type: string, format: date-time }
        response_reason: { type: string }
        responded_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    AssistanceTarget:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }

    AssistanceTargets:
      type: object
      required: [users, teams]
      properties:
        users:
          type: array
          items: { $ref: "#/components/schemas/AssistanceTarget" }
        teams:
          type: array
          items: { $ref: "#/components/schemas/AssistanceTarget" }

    CreateAssistanceRequest:
      type: object
      required: [role, message]
      oneOf:
        - required: [target_user_id]
        - required: [target_team_id]
      properties:
        target_user_id: { type: string, format: uuid }
        target_team_id: { type: string, format: uuid }
        role: { type: string, enum: [incident_commander, primary_responder, subject_matter_expert, communications_lead, observer] }
        message: { type: string, minLength: 1, maxLength: 1000 }

    TimelineEntry:
      type: object
      required: [seq, kind, at]
      properties:
        seq: { type: integer, format: int64, description: Monotonic change cursor. }
        kind: { type: string, example: notification_dispatched }
        at: { type: string }
        data: { type: object }

    AckResult:
      type: object
      required: [status, by]
      properties:
        status: { type: string, example: acknowledged }
        by: { type: string, description: Identity resolved from the bearer token. }
