openapi: "3.1.0"
info:
  title: Agent Notifier API
  version: "0.3.0"
  description: |
    Push notification platform for AI agents. Enables agents to send
    typed notifications to a developer's iPhone with project grouping,
    action buttons, and webhook callbacks.
  contact:
    name: Agent Notifier
    url: https://github.com/yashness/agent-notifier

servers:
  - url: https://notifier.aicrew.in/api
    description: Production API root; versioned paths are under /v1
  - url: http://localhost:8080
    description: Local development

paths:
  /health:
    get:
      operationId: getHealth
      summary: Liveness check
      tags: [System]
      responses:
        "200":
          description: Service is alive
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"

  /ready:
    get:
      operationId: getReady
      summary: Readiness check
      tags: [System]
      responses:
        "200":
          description: Service is ready to accept traffic
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"

  /v1/messages:
    post:
      operationId: sendMessage
      summary: Send a notification
      description: |
        Push a typed notification to the user's devices. Requires an API key
        in the X-API-Key header. Legacy project-scoped keys always send to
        their bound project. Account-scoped keys resolve the request `project`
        value as a project name owned by the key's user and auto-create that
        project on first send. Supplying a UUID `client_event_id` makes safe
        retries idempotent: repeated sends for the same project/client event
        return the original notification ID instead of creating duplicates.
      tags: [Messages]
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendMessageRequest"
      responses:
        "202":
          description: Message accepted for delivery
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendMessageResponse"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Project resolution, persistence, or delivery fan-out failed before acceptance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/messages/{id}/status:
    get:
      operationId: getMessageStatus
      summary: Get notification status by ID
      description: Lightweight API-key endpoint for checking async TTS status after `POST /v1/messages` returns.
      tags: [Messages]
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Notification status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageStatusResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Notification not found for this API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/approvals:
    post:
      operationId: createApproval
      summary: Send and optionally wait for a human approval
      description: |
        Higher-level HITL primitive for agent hooks. Creates an approval
        notification with structured decisions and either returns immediately
        (`wait=none`), long-polls until a decision or timeout (`wait=block`),
        or streams `sent`/`decided` events with Server-Sent Events (`wait=sse`
        or `Accept: text/event-stream`). Block-mode responses are auto-acked
        after the server shapes the decision through the selected
        `response_format` renderer.
      tags: [Approvals]
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateApprovalRequest"
      responses:
        "200":
          description: Approval decided or timed out in block mode
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApprovalDecisionResponse"
            text/event-stream:
              schema:
                type: string
                description: SSE stream of sent/decided events.
        "202":
          description: Approval notification accepted without waiting
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApprovalAcceptedResponse"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/media:
    post:
      operationId: uploadMedia
      summary: Upload notification media
      description: |
        Upload one local media/file attachment to the project's configured
        Azure Blob container. The server detects kind from Content-Type and
        returns a structured attachment object that can be passed to
        /v1/messages. Local filesystem paths are never part of this contract;
        clients should send only the returned blob fields in message payloads.
      tags: [Messages]
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Image/audio/video/file to upload; maximum 25 MiB except video at 50 MiB.
                duration_ms:
                  type: integer
                  format: int64
                  minimum: 0
                  description: Optional client-probed audio/video duration in milliseconds.
      responses:
        "201":
          description: Media uploaded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaUploadResponse"
        "400":
          description: Invalid multipart upload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "413":
          description: Media file exceeds the per-kind size limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "502":
          description: Azure Blob upload failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: Azure Blob media upload is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/devices:
    get:
      operationId: listDevices
      summary: List registered devices
      description: |
        Return device registrations for the authenticated user. Supports
        incremental refresh with updated_after (or the since alias) based on
        device creation time. Device delete tombstones are not yet emitted.
      tags: [Devices]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/UpdatedAfter"
        - $ref: "#/components/parameters/Since"
      responses:
        "200":
          description: Device list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceListResponse"
        "400":
          description: Invalid updated_after timestamp
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: registerDevice
      summary: Register an APNs device token
      description: |
        Register a device to receive push notifications. Called by the
        iOS app after obtaining an APNs device token.
      tags: [Devices]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeviceRegisterRequest"
      responses:
        "201":
          description: Device registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceRegisterResponse"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing user identification
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Device already registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/devices/{id}:
    delete:
      operationId: unregisterDevice
      summary: Unregister a device
      description: Remove a device registration so it no longer receives pushes.
      tags: [Devices]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Device ID to remove
      responses:
        "204":
          description: Device unregistered
        "401":
          description: Missing user identification
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Device not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Auth ──────────────────────────────────────────────────────────────

  /v1/auth/apple:
    post:
      operationId: signInWithApple
      summary: Sign in with Apple
      description: |
        Exchange an Apple identity token for an Agent Notifier JWT pair.
        Creates the user on first sign-in.
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AppleSignInRequest"
      responses:
        "200":
          description: Authenticated successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokenResponse"
        "400":
          description: Invalid identity token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/auth/google:
    post:
      operationId: signInWithGoogle
      summary: Sign in with Google (not enabled in v1)
      description: |
        Safe placeholder only. Per D38, v1 remains Apple-only until
        provider-neutral account linking, nonce/state validation, privacy
        review, and security sign-off are complete. The server validates the
        request shape but does not accept Google tokens.
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GoogleSignInRequest"
      responses:
        "501":
          description: Google auth is not enabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UnsupportedResponse"
        "400":
          description: Invalid request body or missing id_token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/auth/refresh:
    post:
      operationId: refreshToken
      summary: Refresh access token
      description: Exchange a valid refresh token for a new access/refresh pair.
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefreshTokenRequest"
      responses:
        "200":
          description: Tokens refreshed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokenResponse"
        "401":
          description: Invalid or expired refresh token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── User / Me ─────────────────────────────────────────────────────────

  /v1/me:
    get:
      operationId: getMe
      summary: Get current user profile
      description: Returns the authenticated user's profile and tier info.
      tags: [User]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: User profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/me/settings:
    put:
      operationId: updateMySettings
      summary: Update user settings
      description: Update notification preferences, display name, etc.
      tags: [User]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserSettingsRequest"
      responses:
        "200":
          description: Settings updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Notifications (feed) ──────────────────────────────────────────────

  /v1/notifications:
    get:
      operationId: listNotifications
      summary: List notifications (feed)
      description: |
        Paginated list of the user's notifications, newest first.
        Supports filtering by type and project. Normal app refreshes should
        pass updated_after (or since) to retrieve only notifications created
        after the timestamp plus notifications with newer recorded actions.
      tags: [Notifications]
      security:
        - bearerAuth: []
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum: [alert, progress, session_start, session_end, url, file]
          description: Filter by notification type
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter by project
        - $ref: "#/components/parameters/UpdatedAfter"
        - $ref: "#/components/parameters/Since"
        - name: cursor
          in: query
          schema:
            type: string
          description: Cursor for keyset pagination (notification ID)
        - name: limit
          in: query
          schema:
            oneOf:
              - type: integer
                minimum: 0
              - type: string
                enum: [all]
            default: 50
          description: Max results per page. Use 0 or all only for validation/backfill no-cap reads; normal app sync should keep paginating.
      responses:
        "200":
          description: Paginated notification list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: Invalid updated_after timestamp
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/notifications/search:
    get:
      operationId: searchNotifications
      summary: Full-text search over notifications
      description: |
        Returns notifications whose title or message matches the supplied
        full-text query. Backed by a generated tsvector + GIN index. The
        same filter, cursor, and limit conventions as `/v1/notifications`
        apply. The `q` parameter is required.
      tags: [Notifications]
      security:
        - bearerAuth: []
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 256
          description: Full-text query (Postgres English plainto_tsquery semantics).
        - name: type
          in: query
          schema:
            type: string
            enum: [alert, progress, session_start, session_end, url, file]
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
        - $ref: "#/components/parameters/UpdatedAfter"
        - $ref: "#/components/parameters/Since"
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            oneOf:
              - type: integer
                minimum: 0
              - type: string
                enum: [all]
            default: 50
      responses:
        "200":
          description: Paginated notification list (newest first)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationListResponse"
        "400":
          description: Missing or invalid query
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/notifications/read:
    post:
      operationId: markNotificationsRead
      summary: Batch mark notifications as read
      description: |
        Marks notifications as read for the authenticated user. Supply
        EITHER `ids` (UUIDs to mark) OR `before` (RFC3339 timestamp; every
        unread notification created at or before is marked). Mutually
        exclusive — supplying both returns 400. An empty body is a
        successful no-op (`updated_count: 0`) so clients can call this
        endpoint defensively. Idempotent: rows already read are skipped
        and the returned count reflects only newly-marked rows.
      tags: [Notifications]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MarkNotificationsReadRequest"
      responses:
        "200":
          description: Batch mark-read result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarkNotificationsReadResponse"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Projects ──────────────────────────────────────────────────────────

  /v1/projects:
    get:
      operationId: listProjects
      summary: List user's projects
      description: |
        Returns all projects belonging to the authenticated user. Supports
        incremental refresh with updated_after (or since) based on project
        creation time and project settings updated_at. Project metadata edits
        require a future projects.updated_at column for perfect incremental
        detection.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/UpdatedAfter"
        - $ref: "#/components/parameters/Since"
      responses:
        "200":
          description: Project list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: Invalid updated_after timestamp
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createProject
      summary: Create a project
      description: |
        Create a new project (logical sender). An API key is automatically
        generated and returned in the response.
      tags: [Projects]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateProjectRequest"
      responses:
        "201":
          description: Project created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectWithKey"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}:
    get:
      operationId: getProject
      summary: Get a project
      description: Returns a single project with its settings and stats.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
      responses:
        "200":
          description: Project details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    put:
      operationId: updateProject
      summary: Update a project
      description: Update name, icon, color, or other project metadata.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateProjectRequest"
      responses:
        "200":
          description: Project updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectDetail"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: deleteProject
      summary: Delete a project
      description: Soft-delete a project and revoke its API key.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
      responses:
        "204":
          description: Project deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/stats:
    get:
      operationId: getProjectStats
      summary: Get exact project counters
      description: |
        Returns exact counters for a project owned by the authenticated user.
        Includes backwards-compatible `total_notifications`/`active_sessions`
        aliases plus iOS-friendly `notifications_total`/`sessions_total`/
        `live_sessions` counters.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
      responses:
        "200":
          description: Exact project stats
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectStats"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/insights:
    get:
      operationId: getProjectInsights
      summary: Get project insights
      description: |
        Returns live aggregate analytics for a project owned by the authenticated
        user. Empty aggregate collections are encoded as empty arrays/maps, not
        null, so clients can render the Insights tab without special casing.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
        - name: range
          in: query
          required: false
          schema:
            type: string
            enum: [24h, 7d, 30d]
            default: 7d
          description: Time range to aggregate.
      responses:
        "200":
          description: Project insights
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectInsights"
        "400":
          description: Invalid range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/sessions/{session_id}/insights:
    get:
      operationId: getProjectSessionInsights
      summary: Get session insights
      description: Returns aggregate analytics for one session inside a project.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
        - name: session_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Session ID
      responses:
        "200":
          description: Session insights
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionInsights"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project or session not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/settings:
    put:
      operationId: updateProjectSettings
      summary: Update project settings
      description: |
        Update delivery rules, mute/favorite state, custom icon/color,
        list membership, and minimum push priority.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProjectSettingsRequest"
      responses:
        "200":
          description: Settings updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectSettingsResponse"
        "400":
          description: Validation error, including invalid or foreign list IDs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/sessions:
    get:
      operationId: listProjectSessions
      summary: List sessions for a project
      description: |
        Paginated list of sessions within a project. Supports incremental
        refresh with updated_after (or since) based on started_at and ended_at.
        In-flight progress updates need a future session updated_at field for
        complete incremental detection.
      tags: [Projects]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ProjectID"
        - name: status
          in: query
          schema:
            type: string
            enum: [live, complete, error]
          description: Filter by session status
        - $ref: "#/components/parameters/UpdatedAfter"
        - $ref: "#/components/parameters/Since"
        - name: cursor
          in: query
          schema:
            type: string
          description: Cursor for pagination
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: Invalid updated_after timestamp
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/sessions:
    get:
      operationId: listSessions
      summary: List API-key project sessions
      tags: [Sessions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
        - name: project
          in: query
          schema:
            type: string
        - name: active
          in: query
          schema:
            type: boolean
        - name: status
          in: query
          schema:
            type: string
            enum: [live, complete, error]
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionListResponse"
    post:
      operationId: createSession
      summary: Create an API-key session
      tags: [Sessions]
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSessionRequest"
      responses:
        "201":
          description: Created session
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionItem"

  /v1/sessions/{id}:
    get:
      operationId: getSession
      summary: Get an API-key session
      tags: [Sessions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Session detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionItem"

  /v1/sessions/{id}/tree:
    get:
      operationId: getSessionTree
      summary: Get a session notification tree
      tags: [Sessions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Session tree
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionTreeResponse"

  /v1/sessions/{id}/responses:
    get:
      operationId: listSessionResponses
      summary: List action responses for one session
      tags: [Sessions, Actions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Session-scoped action responses
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActionResponseListResponse"

  /v1/sessions/{id}/end:
    post:
      operationId: endSession
      summary: Mark a session complete or errored
      tags: [Sessions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EndSessionRequest"
      responses:
        "200":
          description: Ended session
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionItem"

  # ── API Keys ──────────────────────────────────────────────────────────

  /v1/api-keys:
    get:
      operationId: listAPIKeys
      summary: List API keys
      description: |
        List all API keys for the authenticated user. Key secrets are
        never returned — only prefix, scope, project, and status. Supports
        incremental refresh with updated_after (or since) based on created_at
        and revoked_at.
      tags: [APIKeys]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/UpdatedAfter"
        - $ref: "#/components/parameters/Since"
      responses:
        "200":
          description: API key list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKeyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: Invalid updated_after timestamp
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createAPIKey
      summary: Create an account API key
      description: |
        Issue a new account-scoped API key for the authenticated user without
        revoking existing keys. The full key is returned exactly once. Users
        are limited to 10 active API keys for now.
      tags: [APIKeys]
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAPIKeyRequest"
      responses:
        "201":
          description: Account API key issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RotateAPIKeyResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: API key limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/api-keys/rotate:
    post:
      operationId: rotateAPIKey
      summary: Rotate a legacy project API key
      description: |
        Legacy compatibility endpoint: revoke active project-scoped keys for a
        project and issue a new project-scoped replacement. Prefer
        `/v1/api-keys/{id}/rotate` for account-scoped key lifecycle operations.
        The new full key is returned exactly once in the response.
      tags: [APIKeys]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RotateAPIKeyRequest"
      responses:
        "200":
          description: New API key issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RotateAPIKeyResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: API key limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/api-keys/{id}:
    delete:
      operationId: revokeAPIKey
      summary: Revoke an API key
      description: |
        Revoke one API key by ID for the authenticated user. The key secret and
        hash are never returned. The operation is scoped to the bearer-token user;
        keys owned by another user are reported as not found.
      tags: [APIKeys]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: API key ID to revoke
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: API key revoked
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: API key not found for this user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/api-keys/{id}/rotate:
    post:
      operationId: rotateAPIKeyByID
      summary: Rotate an API key by ID
      description: |
        Atomically revoke one active API key owned by the authenticated user and
        issue a replacement with the same scope/project binding. The replacement
        full key is returned exactly once. Rotating an active key at the 10-key
        account limit is allowed because the old key is revoked as part of the
        same lifecycle operation.
      tags: [APIKeys]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Active API key ID to rotate
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Replacement API key issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RotateAPIKeyResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Active API key not found for this user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: API key limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Actions ───────────────────────────────────────────────────────────

  /v1/actions:
    post:
      operationId: recordAction
      summary: Record a user action
      description: |
        Called by the iOS app when the user taps an action button on a
        notification. Server records the action and optionally triggers
        the project's webhook.
      tags: [Actions]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RecordActionRequest"
      responses:
        "201":
          description: Action recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecordActionResponse"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/action-responses:
    get:
      operationId: pollActionResponses
      x-ios-client-required: false
      summary: Poll pending action/reply responses for agents
      description: |
        API-key authenticated agent inbox for durable two-way communication.
        Returns pending user button taps and text replies scoped to the API
        key's project. Agents should ack a response only after local handling
        succeeds. Account-scoped API keys must pass project_id or project so
        the inbox is still explicitly bound to one project owned by the account.
      tags: [Actions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
          description: Project UUID selector for account-scoped API keys; ignored for legacy project-scoped keys. Takes precedence over project.
        - name: project
          in: query
          schema:
            type: string
          description: Project name selector for account-scoped API keys when project_id is not available; ignored for legacy project-scoped keys.
        - name: session_id
          in: query
          schema:
            type: string
            format: uuid
          description: Optional server-side session UUID to filter responses.
        - name: notification_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter responses for one notification.
        - name: parent_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter responses for a parent notification/subtree root.
        - name: kind
          in: query
          schema:
            type: string
            enum: [button, reply]
          description: Filter by response kind.
        - name: client_event_id
          in: query
          schema:
            type: string
          description: Filter by action or notification client correlation ID.
        - name: approval_id
          in: query
          schema:
            type: string
          description: Filter by first-class approval ID stored on the source notification.
        - name: decision_key
          in: query
          schema:
            type: string
          description: Filter by structured approval decision key, action text, or button label.
        - name: after
          in: query
          schema:
            type: string
            format: date-time
          description: Optional RFC3339 lower bound on recorded_at.
        - name: decided_after
          in: query
          schema:
            type: string
            format: date-time
          description: Optional RFC3339 lower bound on the user's tap/reply timestamp.
        - name: cursor
          in: query
          schema:
            type: string
            format: uuid
          description: Cursor from next_cursor for deterministic keyset pagination by recorded_at/id.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: unacked_only
          in: query
          schema:
            type: boolean
            default: true
          description: When false, includes processed/ignored/failed responses too.
      responses:
        "200":
          description: Pending action/reply responses for the project API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActionResponseListResponse"
        "400":
          description: Invalid query parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/action-responses/stream:
    get:
      operationId: streamActionResponses
      x-ios-client-required: false
      summary: Stream action/reply responses for agents
      description: |
        Server-Sent Events replacement for polling. Uses the same filters as
        `GET /v1/action-responses`; when `unacked_only` is omitted the stream
        includes all response statuses. Resume with `Last-Event-ID` or `cursor`.
      tags: [Actions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
          description: Project UUID selector for account-scoped API keys.
        - name: project
          in: query
          schema:
            type: string
          description: Project name selector for account-scoped API keys.
        - name: session_id
          in: query
          schema:
            type: string
            format: uuid
        - name: cursor
          in: query
          schema:
            type: string
            format: uuid
        - name: since
          in: query
          schema:
            type: string
            format: date-time
          description: Alias for reconnect lower-bound; clients may also use `after`.
      responses:
        "200":
          description: SSE stream of action_response events and heartbeats
          content:
            text/event-stream:
              schema:
                type: string
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/action-responses/{id}/ack:
    post:
      operationId: ackActionResponse
      x-ios-client-required: false
      summary: Acknowledge an action/reply response
      description: |
        Mark an action/reply response as processed, ignored, or failed after
        the local agent handler accepts it. Legacy project keys scope directly
        through the X-API-Key project. Account-scoped API keys must pass
        project_id or project so guessed cross-project IDs still return 404.
      tags: [Actions]
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
          description: Project UUID selector for account-scoped API keys; ignored for legacy project-scoped keys. Takes precedence over project.
        - name: project
          in: query
          schema:
            type: string
          description: Project name selector for account-scoped API keys when project_id is not available; ignored for legacy project-scoped keys.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AckActionResponseRequest"
      responses:
        "200":
          description: Action response acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AckActionResponseResponse"
        "400":
          description: Invalid ack status or body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Action response not found for this project API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/test/simulate-decision:
    post:
      operationId: simulateDecision
      x-ios-client-required: false
      summary: Inject a simulated approval decision for tests
      description: |
        Dev/test endpoint for E2E harnesses. Requires an API key created with
        `test_mode=true`; production keys receive 403. Records a button action
        against an existing notification without requiring a real phone tap.
      tags: [Actions, Testing]
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SimulateDecisionRequest"
      responses:
        "201":
          description: Simulated decision recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecordActionResponse"
        "403":
          description: Test-mode API key required
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/subscriptions/verify:
    post:
      operationId: verifySubscription
      summary: Verify an Apple subscription transaction
      description: |
        Validates StoreKit signed transaction input. In local/unconfigured
        environments, returns an honest `unconfigured` response instead of
        trusting client claims. Full App Store Server API/JWS verification is
        deferred until Apple credentials are configured.
      tags: [Subscriptions]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriptionVerifyRequest"
      responses:
        "200":
          description: Transaction verified and entitlement updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionVerifyResponse"
        "400":
          description: Missing or invalid signed transaction
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Apple JWS verification is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionVerifyResponse"

  /v1/subscriptions/stripe/checkout:
    post:
      operationId: createStripeCheckout
      summary: Create Stripe checkout session (stub)
      description: Stripe checkout is scaffolded for future web billing and returns 501 until configured.
      tags: [Subscriptions]
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StripeCheckoutRequest"
      responses:
        "501":
          description: Stripe checkout is not implemented
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StripeCheckoutResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/crashes:
    post:
      operationId: submitCrashReport
      summary: Submit an iOS crash report
      description: |
        Submit a crash report captured by the iOS app on a previous launch.
        This endpoint is for real app crash telemetry only; the app does not
        expose a synthetic crash-report submitter in Settings.
      tags: [CrashReports]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CrashReportRequest"
      responses:
        "201":
          description: Crash report accepted and stored
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CrashReportResponse"
        "400":
          description: Invalid request body or missing reason
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Crash-report storage is unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Lists ─────────────────────────────────────────────────────────────

  /v1/lists:
    get:
      operationId: listLists
      summary: List user's project lists
      description: |
        Returns all user-defined project groupings. Supports incremental
        refresh with updated_after (or since) based on created_at. List
        rename/reorder/delete and membership changes require future updated_at
        or tombstone support for perfect incremental detection.
      tags: [Lists]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/UpdatedAfter"
        - $ref: "#/components/parameters/Since"
      responses:
        "200":
          description: List of lists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: Invalid updated_after timestamp
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createList
      summary: Create a project list
      description: Create a new user-defined grouping for projects.
      tags: [Lists]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateListRequest"
      responses:
        "201":
          description: List created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectList"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/lists/{id}:
    put:
      operationId: updateList
      summary: Update a project list
      description: Rename or reorder a list.
      tags: [Lists]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateListRequest"
      responses:
        "200":
          description: List updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectList"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: List not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: deleteList
      summary: Delete a project list
      description: Remove a user-defined list. Projects in it are not deleted.
      tags: [Lists]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: List deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: List not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  # ── Admin (read-only) ───────────────────────────────────────────────

  /v1/admin/overview:
    get:
      operationId: getAdminOverview
      summary: Get admin dashboard overview
      description: |
        Read-only platform aggregates for the admin dashboard, backed by
        existing Neon/Postgres tables. Requires the server-side admin API key.
      tags: [Admin]
      security:
        - adminApiKeyAuth: []
      responses:
        "200":
          description: Admin overview data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminOverviewResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Admin API or store is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/admin/users:
    get:
      operationId: listAdminUsers
      summary: List users for admin
      description: Read-only user list with project and notification counts.
      tags: [Admin]
      security:
        - adminApiKeyAuth: []
      parameters:
        - name: q
          in: query
          schema:
            type: string
          description: Optional email/display-name search query
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Admin user list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminUsersResponse"
        "400":
          description: Invalid query parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Admin API or store is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/admin/api-keys:
    get:
      operationId: listAdminAPIKeys
      summary: List API keys for admin
      description: |
        Read-only API-key inventory with owner/project context. Secrets and key
        hashes are never returned; only public prefixes are shown.
      tags: [Admin]
      security:
        - adminApiKeyAuth: []
      parameters:
        - name: q
          in: query
          schema:
            type: string
          description: Optional prefix/email/project-name search query
        - name: include_revoked
          in: query
          schema:
            type: boolean
            default: false
          description: Include revoked keys in the inventory
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Admin API-key inventory
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAPIKeysResponse"
        "400":
          description: Invalid query parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Admin API or store is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/admin/activity:
    get:
      operationId: listAdminActivity
      summary: List admin activity events
      description: Read-only newest-first notification activity for admin review.
      tags: [Admin]
      security:
        - adminApiKeyAuth: []
      parameters:
        - name: type
          in: query
          schema:
            type: string
          description: Optional notification type filter
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
          description: Optional project filter
        - name: user_id
          in: query
          schema:
            type: string
            format: uuid
          description: Optional user filter
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Admin activity list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminActivityListResponse"
        "400":
          description: Invalid query parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Admin API or store is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/admin/settings:
    get:
      operationId: getAdminSettings
      summary: Get safe runtime settings for admin
      description: |
        Returns non-secret runtime configuration status for the admin UI.
        Secret values are never returned.
      tags: [Admin]
      security:
        - adminApiKeyAuth: []
      responses:
        "200":
          description: Safe admin settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminSettingsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Admin API is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/admin/crashes:
    get:
      operationId: listAdminCrashReports
      summary: List recent crash reports for admin
      description: |
        Read-only recent iOS crash reports for operator triage. Requires the
        server-side admin API key and returns newest reports first.
      tags: [Admin, CrashReports]
      security:
        - adminApiKeyAuth: []
      responses:
        "200":
          description: Recent crash reports
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminCrashReportListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Crash-report storage is unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Account-scoped or legacy project-scoped API key in format: prefix.secret"
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: JWT access token from /v1/auth/apple or /v1/auth/refresh
    adminApiKeyAuth:
      type: apiKey
      in: header
      name: X-Admin-API-Key
      description: Server-side admin dashboard key from ADMIN_API_KEY

  parameters:
    ProjectID:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Project ID
    UpdatedAfter:
      name: updated_after
      in: query
      required: false
      schema:
        type: string
        format: date-time
      description: |
        RFC3339 timestamp for incremental sync. When supplied, list endpoints
        return entities created or modified after this value where the current
        schema exposes a reliable timestamp.
    Since:
      name: since
      in: query
      required: false
      schema:
        type: string
        format: date-time
      description: Alias for updated_after for client convenience.

  responses:
    Unauthorized:
      description: Missing or invalid authentication
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    HealthResponse:
      type: object
      required: [status, version]
      properties:
        status:
          type: string
          examples: ["ok", "ready"]
        version:
          type: string
          examples: ["0.1.0"]

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          examples: ["title is required"]

    UnsupportedResponse:
      type: object
      required: [status, error]
      properties:
        status:
          type: string
          enum: [unsupported]
        error:
          type: string

    MessageAttachment:
      type: object
      required: [kind, url, mime_type, size_bytes]
      properties:
        id:
          type: string
          description: Server-assigned blob id when the attachment came from POST /v1/media.
        kind:
          type: string
          enum: [image, audio, video, file]
        url:
          type: string
          format: uri
          description: HTTPS URL for the blob/object.
        mime_type:
          type: string
          description: MIME type used to validate and render the attachment.
        size_bytes:
          type: integer
          format: int64
          minimum: 0
        duration_ms:
          type: integer
          format: int64
          minimum: 0
          nullable: true
          description: Audio/video duration in milliseconds when known.
        file_name:
          type: string
          description: Original filename for kind=file and generic display.
        thumbnail_url:
          type: string
          format: uri
          description: Optional HTTPS preview thumbnail for image/video.

    NotificationColor:
      type: string
      enum: [red, orange, amber, lime, green, teal, cyan, blue, purple, pink]
      description: |
        Fixed notification display palette. Clients render accessible light and
        dark variants from these names rather than accepting arbitrary hex.
        Alerts default to orange when omitted.

    SendMessageRequest:
      type: object
      required: [type, title]
      properties:
        project:
          type: string
          description: Project name or ID. Required for account-scoped API keys; legacy project-scoped keys keep their server binding.
        session_id:
          type: string
          format: uuid
          description: Active session to associate with
        type:
          type: string
          enum: [alert, progress, session_start, session_end, url, file]
          description: Notification type
        title:
          type: string
          description: Notification title (shown in push alert)
          maxLength: 256
        message:
          type: string
          description: Notification body text
          maxLength: 4096
        priority:
          type: integer
          minimum: -1
          maximum: 2
          default: 0
          description: |
            -1 = silent (no alert), 0 = normal, 1 = high (bypasses DND),
            2 = critical (requires entitlement)
        progress:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Progress value for progress-type notifications
        image_url:
          type: string
          format: uri
          deprecated: true
          description: |
            Deprecated. HTTPS URL to an image attachment. Server validation rejects
            localhost/private-network hosts, file URLs, credentials, and
            non-HTTPS/custom schemes. Prefer image_url values returned by
            POST /v1/media for owned Azure Blob media. New clients should send
            attachment instead; when both are present, attachment is authoritative.
        attachment:
          $ref: "#/components/schemas/MessageAttachment"
        attachments:
          type: array
          maxItems: 12
          items:
            $ref: "#/components/schemas/MessageAttachment"
          description: Multiple media attachments. `attachment` remains a legacy alias for the first/primary item.
        as_audio:
          type: boolean
          default: false
          description: |
            When true, the API accepts the notification immediately, records
            `metadata.tts_status=queued`, generates Kokoro speech audio in the
            background, appends the resulting `audio` attachment, and sends the
            rich push when audio is ready. May be combined with `attachments`
            and `image_url`.
        voice:
          type: string
          default: af_bella
          pattern: '^[a-z]{2}_[a-z0-9_]{1,48}$'
          description: Kokoro voice id used when `as_audio` is true.
        voice_engine:
          type: string
          enum: [kokoro]
          default: kokoro
          description: TTS engine used when `as_audio` is true.
        speed:
          type: number
          format: float
          minimum: 0.5
          maximum: 2.0
          default: 1.0
          description: Kokoro speech speed used when `as_audio` is true.
        rewrite_for_tts:
          type: boolean
          default: false
          description: Reserved future flag for server-side text rewriting before TTS. Currently must remain false.
        tts_text:
          type: string
          maxLength: 12000
          description: Optional explicit text to synthesize when `as_audio` is true. Useful when attaching a Markdown/text file and generating audio from its contents.
        url:
          type: string
          format: uri
          description: Clickable URL
        url_title:
          type: string
          description: Display text for the URL
        buttons:
          type: array
          items:
            type: string
          maxItems: 4
          description: Action button labels (max 4, Pro users)
        sound:
          type: string
          default: default
          description: Push notification sound name
        ttl:
          type: integer
          minimum: 0
          description: |
            Time-to-live in seconds. When push delivery is configured, this
            maps to the APNs apns-expiration header; 0 requests immediate-only
            delivery.
        device:
          type: string
          description: Target specific device by name
        color:
          $ref: "#/components/schemas/NotificationColor"
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 64
          description: |
            Optional safe, non-secret flat metadata for renderer hints and
            agent hook diagnostics. Hook clients use stable `hook.v1` keys:
            `agent_notifier_schema`, `hook_source`, `hook_scope`, `hook_path`,
            `hook_script`, `hook_event`, `hook_event_type`, `hook_tool`,
            `hook_project`, `hook_project_id`, `hook_cwd`, `hook_session_id`,
            `hook_session_hash`, `hook_api_session_id`, `hook_live_status`,
            `hook_subject_hash`, `hook_file_path`, `hook_file_path_count`,
            `hook_tool_input_keys`, `hook_error_present`, `hook_status`,
            `hook_exit_code`, `hook_duration_ms`,
            `hook_prompt_length`, `hook_prompt_hash`, `hook_prompt_excerpt`,
            `hook_command_length`, `hook_command_hash`, `hook_command_excerpt`,
            `hook_tool_input_length`, `hook_tool_input_hash`,
            `hook_tool_input_excerpt`, `hook_tool_response_length`,
            `hook_tool_response_hash`, `hook_tool_response_excerpt`,
            `hook_thinking_length`, `hook_thinking_hash`,
            `hook_thinking_excerpt`, `hook_error_length`, `hook_error_hash`,
            and `hook_error_excerpt`. Excerpt fields are bounded and locally
            redacted before send; proof metadata omits excerpts, while
            detail-mode JSONL logs keep a redacted `details` object. Server-side
            validation drops obvious secret-bearing keys, drops secret-shaped
            values, and truncates long values.
        event:
          type: string
          maxLength: 64
          description: |
            Hook event name (e.g. `UserPromptSubmit`, `PreToolUse`,
            `PostToolUse`, `SubagentStart`, `SubagentStop`, `SessionEnd`,
            `AgentStop`, `PreCompact`). Promoted out of `metadata.hook_event`
            in plan 2026-04-28 (Phase 1) so iOS/admin can render the
            substance of the event without parsing metadata.
        tool_name:
          type: string
          maxLength: 128
          description: Name of the tool the agent invoked (when applicable).
        command:
          type: string
          maxLength: 4000
          description: |
            Shell command or tool input the agent ran. Redact locally before
            sending; the server only does length capping.
        prompt:
          type: string
          maxLength: 8000
          description: Prompt text the agent submitted (redacted, capped).
        file_paths:
          type: array
          items:
            type: string
            maxLength: 512
          maxItems: 12
          description: Metadata-only file paths referenced by the event. This field does not upload bytes; use POST /v1/media and attachment for user-openable files.
        parent_id:
          type: string
          format: uuid
          description: |
            ID of an immediately prior notification in the same session,
            used to render parent/subagent threads. Server validates that
            the parent belongs to the caller's project.
        subagent_id:
          type: string
          maxLength: 128
          description: |
            Caller-supplied opaque thread identifier for grouping subagent
            events within a session.
        status:
          type: string
          maxLength: 64
          description: Short status word (e.g. `submitted`, `completed`).
        client_event_id:
          type: string
          format: uuid
          description: Optional caller-generated idempotency key. For a given project, duplicate client_event_id values return the original notification instead of inserting a duplicate. Recommended for CLIs and unattended agents that retry transient failures.

    SendMessageResponse:
      type: object
      required: [id]
      properties:
        id:
          type: string
          format: uuid
          description: Notification ID
        delivered_at:
          type: string
          format: date-time
          description: Timestamp when the notification was accepted
        action:
          type: string
          description: User action (populated on poll/webhook)
        button_index:
          type: integer
          description: Index of tapped button
        project_name:
          type: string
          description: Resolved project name for diagnostics, especially account-scoped API keys.
        user_hash:
          type: string
          description: Short non-PII hash of the owning user used to diagnose account mismatches without exposing user IDs.
        audio_status:
          type: string
          enum: [queued, running, ready, failed]
          description: Async TTS status when `as_audio` was requested.

    MessageStatusResponse:
      type: object
      required: [id, attachments_count]
      properties:
        id:
          type: string
          format: uuid
        audio_status:
          type: string
          enum: [queued, running, ready, failed]
          description: Current async TTS status, derived from notification metadata and attachments.
        attachments_count:
          type: integer
          minimum: 0
        attachment:
          $ref: "#/components/schemas/MessageAttachment"
        attachments:
          type: array
          items:
            $ref: "#/components/schemas/MessageAttachment"
        metadata:
          type: object
          additionalProperties:
            type: string

    ApprovalDecisionRequest:
      type: object
      required: [key, label]
      properties:
        key:
          type: string
          maxLength: 64
          description: Stable machine key such as allow or deny.
        label:
          type: string
          maxLength: 64
          description: Human-readable button label.
        icon:
          type: string
        color:
          type: string
        dangerous:
          type: boolean
          default: false
        is_default_on_timeout:
          type: boolean
          default: false

    CreateApprovalRequest:
      type: object
      required: [session_id, title, decisions]
      properties:
        project:
          type: string
          description: Project name for account-scoped API keys.
        project_id:
          type: string
          format: uuid
          description: Project UUID for account-scoped API keys.
        session_id:
          type: string
          format: uuid
        title:
          type: string
        message:
          type: string
        event:
          type: string
        tool_name:
          type: string
        command:
          type: string
        decisions:
          type: array
          minItems: 1
          maxItems: 4
          items:
            $ref: "#/components/schemas/ApprovalDecisionRequest"
        timeout_seconds:
          type: integer
          minimum: 1
          maximum: 600
          default: 300
        response_format:
          type: string
          enum: [raw, claude_code.pretooluse, claude_code.userpromptsubmit, copilot_cli.pretooluse, copilot_cli.permissionrequest, codex.permission, cursor.beforeshellexecution]
          default: raw
        wait:
          type: string
          enum: [none, block, sse]
          default: block
        metadata:
          type: object
          additionalProperties:
            type: string
        client_event_id:
          type: string
          format: uuid

    ApprovalAcceptedResponse:
      type: object
      required: [approval_id, notification_id, status]
      properties:
        approval_id:
          type: string
          format: uuid
        notification_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [sent]
        expires_at:
          type: string
          format: date-time

    ApprovalDecisionResult:
      type: object
      required: [key, label, decided_at, is_timeout_default]
      properties:
        key:
          type: string
        label:
          type: string
        button_index:
          type: integer
          minimum: 0
          maximum: 3
        response_text:
          type: string
        decided_at:
          type: string
          format: date-time
        decided_by_device:
          type: string
        is_timeout_default:
          type: boolean

    ApprovalDecisionResponse:
      type: object
      required: [approval_id, notification_id, decision, response_payload]
      properties:
        approval_id:
          type: string
          format: uuid
        notification_id:
          type: string
          format: uuid
        decision:
          $ref: "#/components/schemas/ApprovalDecisionResult"
        response_payload:
          type: object
          additionalProperties: true
          description: Payload shaped by response_format for the calling agent.

    MediaUploadResponse:
      type: object
      required: [id, blob_name, blob_url, image_url, kind, mime_type, size_bytes]
      properties:
        id:
          type: string
          description: Generated media/blob identifier
        blob_name:
          type: string
          description: Azure Blob object name
        blob_url:
          type: string
          format: uri
          description: Direct URL for the uploaded blob
        image_url:
          type: string
          format: uri
          deprecated: true
          description: Deprecated alias for blob_url accepted by legacy SendMessageRequest.image_url
        kind:
          type: string
          enum: [image, audio, video, file]
          description: Server-detected attachment kind from Content-Type.
        mime_type:
          type: string
          description: Uploaded file MIME type used for attachment validation.
        content_type:
          type: string
          deprecated: true
          description: Deprecated alias for mime_type.
        size_bytes:
          type: integer
          format: int64
          description: Uploaded file size from multipart metadata
        duration_ms:
          type: integer
          format: int64
          nullable: true
          description: Optional audio/video duration in milliseconds when supplied by the client.

    DeviceRegisterRequest:
      type: object
      required: [apns_token]
      properties:
        apns_token:
          type: string
          description: APNs device token from UIApplication registration
        name:
          type: string
          description: Human-readable device name

    DeviceRegisterResponse:
      type: object
      required: [id, token_fingerprint, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: Device registration ID
        token_fingerprint:
          type: string
          description: Non-reversible APNs token fingerprint for diagnostics; raw APNs tokens are never returned.
        name:
          type: string
        created_at:
          type: string
          format: date-time

    DeviceItem:
      type: object
      required: [id, token_fingerprint, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: Device registration ID
        token_fingerprint:
          type: string
          description: Non-reversible APNs token fingerprint for diagnostics; raw APNs tokens are never returned.
        name:
          type: string
          description: Human-readable device name
        created_at:
          type: string
          format: date-time

    DeviceListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DeviceItem"

    WebhookPayload:
      type: object
      description: |
        Sent to agent webhook URL when user taps an action button.
        Signed with HMAC-SHA256 (X-Signature-256 header).
        Includes X-Timestamp and X-Nonce for replay prevention (5-min window).
      required: [notification_id, action, button_index, timestamp, nonce]
      properties:
        notification_id:
          type: string
          format: uuid
        action:
          type: string
          description: Action type (e.g., "button_pressed")
        button_index:
          type: integer
          minimum: 0
          maximum: 3
        session_id:
          type: string
          format: uuid
        timestamp:
          type: integer
          format: int64
          description: Unix timestamp
        nonce:
          type: string
          description: Random hex nonce for replay prevention

    # ── Auth schemas ──────────────────────────────────────────────────

    AppleSignInRequest:
      type: object
      required: [identity_token]
      properties:
        identity_token:
          type: string
          description: Apple identity token (JWT) from AuthenticationServices
        display_name:
          type: string
          description: User's display name (provided on first sign-in only)
        email:
          type: string
          format: email
          description: User's email (provided on first sign-in only)
        nonce:
          type: string
          description: Optional OAuth nonce expected in the Apple identity token; used by web Sign in with Apple callbacks.

    GoogleSignInRequest:
      type: object
      required: [id_token]
      properties:
        id_token:
          type: string
          description: Google ID token. Currently shape-validated only; not accepted while D38 blocks full provider expansion.
        nonce:
          type: string
          description: OAuth nonce expected in the token once Google auth is enabled.

    RefreshTokenRequest:
      type: object
      required: [refresh_token]
      properties:
        refresh_token:
          type: string
          description: Refresh token from a previous auth response

    AuthTokenResponse:
      type: object
      required: [access_token, refresh_token, expires_in, user]
      properties:
        access_token:
          type: string
          description: Short-lived JWT access token
        refresh_token:
          type: string
          description: Long-lived refresh token
        expires_in:
          type: integer
          description: Access token lifetime in seconds
        user:
          $ref: "#/components/schemas/UserProfile"

    # ── User schemas ──────────────────────────────────────────────────

    UserProfile:
      type: object
      required: [id, email, tier, created_at]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        display_name:
          type: string
        tier:
          type: string
          enum: [free, pro]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    UpdateUserSettingsRequest:
      type: object
      properties:
        display_name:
          type: string
          maxLength: 100
        notification_sound:
          type: string
          description: Default notification sound

    # ── Notification (feed) schemas ───────────────────────────────────

    NotificationItem:
      type: object
      required: [id, project_id, type, title, priority, created_at]
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        session_id:
          type: string
          format: uuid
        type:
          type: string
          enum: [alert, progress, session_start, session_end, url, file]
        title:
          type: string
        message:
          type: string
        priority:
          type: integer
        progress:
          type: number
          format: float
        image_url:
          type: string
          format: uri
          deprecated: true
        attachment:
          $ref: "#/components/schemas/MessageAttachment"
        attachments:
          type: array
          items:
            $ref: "#/components/schemas/MessageAttachment"
          description: All media attachments for this notification. The legacy `attachment` field is the primary item.
        url:
          type: string
          format: uri
        url_title:
          type: string
        buttons:
          type: array
          items:
            type: string
        sound:
          type: string
        created_at:
          type: string
          format: date-time
        read_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp the notification was marked read; null/absent when unread.
        project:
          $ref: "#/components/schemas/ProjectSummary"
        action:
          type: string
          description: Latest recorded action for this notification, when present.
        button_index:
          type: integer
          minimum: 0
          maximum: 3
          description: Latest tapped action button index, when present.
        action_color:
          type: string
          description: UI accent color derived from the latest action label.
        color:
          $ref: "#/components/schemas/NotificationColor"
        metadata:
          type: object
          additionalProperties:
            type: string
          description: Optional safe, non-secret renderer metadata sent with the original notification, including structured `hook.v1` fields when emitted by hook clients.
        event:
          type: string
          description: Hook event name, e.g. `UserPromptSubmit` (Phase 1 substance field).
        tool_name:
          type: string
          description: Tool the agent invoked (Phase 1 substance field).
        command:
          type: string
          description: Command/tool input the agent ran (Phase 1 substance field).
        prompt:
          type: string
          description: Prompt text the agent submitted (Phase 1 substance field).
        file_paths:
          type: array
          items:
            type: string
          description: File paths referenced by the event (Phase 1 substance field).
        parent_id:
          type: string
          format: uuid
          description: Parent notification id within the same session (Phase 1).
        subagent_id:
          type: string
          description: Caller-supplied subagent thread id (Phase 1).
        status:
          type: string
          description: Short status word (Phase 1 substance field).

    NotificationListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/NotificationItem"
        next_cursor:
          type: string
          description: Cursor for the next page (null if last page)

    MarkNotificationsReadRequest:
      type: object
      description: |
        Provide EITHER `ids` (the notification UUIDs to mark) OR `before`
        (mark every unread notification created at or before). Mutually
        exclusive.
      properties:
        ids:
          type: array
          maxItems: 500
          items:
            type: string
            format: uuid
        before:
          type: string
          format: date-time
          description: RFC3339 timestamp; all unread notifications created ≤ this time are marked.

    MarkNotificationsReadResponse:
      type: object
      required: [updated_count]
      properties:
        updated_count:
          type: integer
          minimum: 0
          description: Number of notifications flipped from unread to read by this call. Zero when no rows matched or all targets were already read.

    # ── Project schemas ───────────────────────────────────────────────

    ProjectSummary:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        icon:
          type: string
        color:
          type: string

    ProjectDetail:
      type: object
      required: [id, name, icon, color, created_at, settings]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        icon:
          type: string
        color:
          type: string
        default_icon:
          type: string
          description: Built-in/default project icon before custom settings are applied.
        default_color:
          type: string
          description: Built-in/default project color before custom settings are applied.
        webhook_url:
          type: string
          format: uri
        created_at:
          type: string
          format: date-time
        settings:
          $ref: "#/components/schemas/ProjectSettingsResponse"
        stats:
          $ref: "#/components/schemas/ProjectStats"

    ProjectStats:
      type: object
      properties:
        total_notifications:
          type: integer
          description: Backwards-compatible alias for notifications_total.
        notifications_total:
          type: integer
          description: Exact total notifications for this project.
        sessions_total:
          type: integer
          description: Exact total sessions for this project.
        active_sessions:
          type: integer
          description: Backwards-compatible alias for live_sessions.
        live_sessions:
          type: integer
          description: Exact count of live sessions for this project.
        last_notification_at:
          type: string
          format: date-time

    ProjectInsights:
      type: object
      required:
        - range
        - generated_at
        - totals
        - session_duration
        - outcomes
        - top_tools
        - top_files_edited
        - failing_commands
        - error_categories
        - agent_sources
        - event_kinds
      properties:
        range:
          type: string
          enum: [24h, 7d, 30d]
        generated_at:
          type: string
          format: date-time
        totals:
          $ref: "#/components/schemas/InsightTotals"
        session_duration:
          $ref: "#/components/schemas/InsightSessionDuration"
        outcomes:
          $ref: "#/components/schemas/InsightOutcomes"
        top_tools:
          type: array
          items:
            $ref: "#/components/schemas/InsightToolCount"
        top_files_edited:
          type: array
          items:
            $ref: "#/components/schemas/InsightFileEdit"
        failing_commands:
          type: array
          items:
            $ref: "#/components/schemas/InsightCommandCount"
        error_categories:
          type: object
          additionalProperties:
            type: integer
        agent_sources:
          type: array
          items:
            $ref: "#/components/schemas/InsightAgentCount"
        event_kinds:
          type: array
          items:
            $ref: "#/components/schemas/InsightEventKindCount"

    SessionInsights:
      type: object
      required: [session_id, generated_at, events_count, outcomes, top_tools, top_files_edited, event_kinds]
      properties:
        session_id:
          type: string
          format: uuid
        generated_at:
          type: string
          format: date-time
        events_count:
          type: integer
          minimum: 0
        outcomes:
          $ref: "#/components/schemas/InsightOutcomes"
        top_tools:
          type: array
          items:
            $ref: "#/components/schemas/InsightToolCount"
        top_files_edited:
          type: array
          items:
            $ref: "#/components/schemas/InsightFileEdit"
        event_kinds:
          type: array
          items:
            $ref: "#/components/schemas/InsightEventKindCount"
        started_at:
          type: string
          format: date-time
        ended_at:
          type: string
          format: date-time

    InsightTotals:
      type: object
      required: [notifications, sessions, active_sessions, tool_calls, subagents]
      properties:
        notifications:
          type: integer
          minimum: 0
        sessions:
          type: integer
          minimum: 0
        active_sessions:
          type: integer
          minimum: 0
        tool_calls:
          type: integer
          minimum: 0
        subagents:
          type: integer
          minimum: 0

    InsightSessionDuration:
      type: object
      required: [avg_ms, median_ms, p95_ms]
      properties:
        avg_ms:
          type: integer
          format: int64
          minimum: 0
        median_ms:
          type: integer
          format: int64
          minimum: 0
        p95_ms:
          type: integer
          format: int64
          minimum: 0

    InsightOutcomes:
      type: object
      required: [success, failed, blocked]
      properties:
        success:
          type: integer
          minimum: 0
        failed:
          type: integer
          minimum: 0
        blocked:
          type: integer
          minimum: 0

    InsightToolCount:
      type: object
      required: [tool_category, count, fail_count]
      properties:
        tool_category:
          type: string
        count:
          type: integer
          minimum: 0
        fail_count:
          type: integer
          minimum: 0

    InsightFileEdit:
      type: object
      required: [path, hits]
      properties:
        path:
          type: string
        hits:
          type: integer
          minimum: 0

    InsightCommandCount:
      type: object
      required: [command, count]
      properties:
        command:
          type: string
        count:
          type: integer
          minimum: 0

    InsightAgentCount:
      type: object
      required: [agent_source, count]
      properties:
        agent_source:
          type: string
        count:
          type: integer
          minimum: 0

    InsightEventKindCount:
      type: object
      required: [event_kind, count]
      properties:
        event_kind:
          type: string
        count:
          type: integer
          minimum: 0

    ProjectListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ProjectDetail"

    CreateProjectRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 100
        icon:
          type: string
          default: "🤖"
          description: Emoji icon
        color:
          type: string
          default: "#00BCD4"
          description: Hex color
        webhook_url:
          type: string
          format: uri
          description: URL to receive action callbacks

    ProjectWithKey:
      type: object
      required: [project, api_key]
      properties:
        project:
          $ref: "#/components/schemas/ProjectDetail"
        api_key:
          type: string
          description: "Full API key (shown once): prefix.secret"

    UpdateProjectRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 100
        icon:
          type: string
        color:
          type: string
        webhook_url:
          type: string
          format: uri

    # ── Project Settings schemas ──────────────────────────────────────

    DeliveryRule:
      type: object
      required: [push, sound]
      properties:
        push:
          type: boolean
        sound:
          type: string
        min_priority:
          type: integer
          minimum: -1
          maximum: 2
          description: Optional per-type priority floor. When set, notifications of this type below `min_priority` are not pushed even if `push` is true.

    ProjectDelivery:
      type: object
      properties:
        alert:
          $ref: "#/components/schemas/DeliveryRule"
        progress:
          $ref: "#/components/schemas/DeliveryRule"
        session_start:
          $ref: "#/components/schemas/DeliveryRule"
        session_end:
          $ref: "#/components/schemas/DeliveryRule"
        url:
          $ref: "#/components/schemas/DeliveryRule"
        file:
          $ref: "#/components/schemas/DeliveryRule"

    ProjectSettingsRequest:
      type: object
      properties:
        favorite:
          type: boolean
        muted:
          type: boolean
        min_push_priority:
          type: integer
          minimum: -1
          maximum: 2
        delivery:
          $ref: "#/components/schemas/ProjectDelivery"
        lists:
          type: array
          items:
            type: string
            format: uuid
          description: List IDs this project belongs to. IDs must belong to the authenticated project owner.
        custom_icon:
          type: string
        custom_color:
          type: string
        quiet_start:
          type: string
          nullable: true
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$"
          description: Optional UTC quiet-hours start as HH:MM or HH:MM:SS. Pair with quiet_end. Empty string clears.
        quiet_end:
          type: string
          nullable: true
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$"
          description: Optional UTC quiet-hours end as HH:MM or HH:MM:SS. When end < start the window wraps past midnight.

    ProjectSettingsResponse:
      type: object
      required: [favorite, muted, min_push_priority, delivery]
      properties:
        favorite:
          type: boolean
        muted:
          type: boolean
        min_push_priority:
          type: integer
        delivery:
          $ref: "#/components/schemas/ProjectDelivery"
        lists:
          type: array
          items:
            type: string
            format: uuid
        custom_icon:
          type: string
        custom_color:
          type: string
        quiet_start:
          type: string
          nullable: true
          description: UTC quiet-hours start as HH:MM:SS, or null when not configured.
        quiet_end:
          type: string
          nullable: true
          description: UTC quiet-hours end as HH:MM:SS, or null when not configured.

    # ── Session schemas ───────────────────────────────────────────────

    SessionItem:
      type: object
      required: [id, project_id, status, progress, started_at]
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        label:
          type: string
        agent:
          type: string
        agent_version:
          type: string
        metadata:
          type: object
          additionalProperties:
            type: string
        status:
          type: string
          enum: [live, complete, error]
        progress:
          type: number
          format: float
        progress_label:
          type: string
        started_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        ended_at:
          type: string
          format: date-time

    CreateSessionRequest:
      type: object
      properties:
        project_id:
          type: string
          format: uuid
        project:
          type: string
        label:
          type: string
        agent:
          type: string
        agent_version:
          type: string
        metadata:
          type: object
          additionalProperties:
            type: string

    EndSessionRequest:
      type: object
      properties:
        status:
          type: string
          enum: [complete, error]

    SessionTreeNode:
      type: object
      required: [id, type, title, message, created_at]
      properties:
        id:
          type: string
          format: uuid
        parent_id:
          type: string
          format: uuid
        session_id:
          type: string
          format: uuid
        type:
          type: string
        title:
          type: string
        message:
          type: string
        event:
          type: string
        tool_name:
          type: string
        command:
          type: string
        prompt:
          type: string
        file_paths:
          type: array
          items:
            type: string
        subagent_id:
          type: string
        status:
          type: string
        client_event_id:
          type: string
        metadata:
          type: object
          additionalProperties:
            type: string
        created_at:
          type: string
          format: date-time
        children:
          type: array
          items:
            $ref: "#/components/schemas/SessionTreeNode"

    SessionTreeResponse:
      type: object
      required: [session, notifications]
      properties:
        session:
          $ref: "#/components/schemas/SessionItem"
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/SessionTreeNode"

    SessionListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/SessionItem"
        next_cursor:
          type: string

    # ── API Key schemas ───────────────────────────────────────────────

    APIKeyItem:
      type: object
      required: [id, project_id, scope, status, prefix, created_at]
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          description: Project UUID for project-scoped keys; empty for account-scoped keys.
        scope:
          type: string
          enum: [project, account]
          description: Whether the key is bound to one project or the whole user account.
        status:
          type: string
          enum: [active, revoked]
          description: Group-friendly lifecycle status derived from revoked_at.
        prefix:
          type: string
          description: Non-secret key prefix for identification
        label:
          type: string
          maxLength: 80
          description: Optional human-readable name supplied at creation time (e.g. "Claude Code · MacBook").
        test_mode:
          type: boolean
          default: false
          description: Allows dev-only simulation endpoints; never grants extra production access.
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          description: Last successful API-key authentication time, when available.

    CreateAPIKeyRequest:
      type: object
      properties:
        label:
          type: string
          maxLength: 80
          description: Optional human-readable name for the key (control characters are stripped, capped at 80 chars).
        test_mode:
          type: boolean
          default: false
          description: Create a key that can use dev-only simulation endpoints.

    SubscriptionVerifyRequest:
      type: object
      required: [signed_transaction_jws]
      properties:
        signed_transaction_jws:
          type: string
          description: StoreKit 2 signed transaction JWS from the client.
        product_id:
          type: string
        transaction_id:
          type: string

    SubscriptionVerifyResponse:
      type: object
      required: [status, entitlement_updated, apple_jws_verification_configured]
      properties:
        status:
          type: string
          enum: [verified, unconfigured, unsupported]
        error:
          type: string
        apple_jws_verification_configured:
          type: boolean
        entitlement_updated:
          type: boolean
        tier:
          type: string
          enum: [free, pro]
        pro_expires_at:
          type: string
          format: date-time

    StripeCheckoutRequest:
      type: object
      properties:
        price_id:
          type: string
        success_url:
          type: string
          format: uri
        cancel_url:
          type: string
          format: uri

    StripeCheckoutResponse:
      type: object
      required: [status, error]
      properties:
        status:
          type: string
          enum: [unsupported]
        error:
          type: string
        checkout_url:
          type: string
          format: uri
          nullable: true

    APIKeyListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/APIKeyItem"

    RotateAPIKeyRequest:
      type: object
      required: [project_id]
      properties:
        project_id:
          type: string
          format: uuid
          description: Project whose legacy project-scoped keys should be rotated

    RotateAPIKeyResponse:
      type: object
      required: [api_key, key]
      properties:
        api_key:
          $ref: "#/components/schemas/APIKeyItem"
        key:
          type: string
          description: "Full new key (shown once): prefix.secret"

    # ── Action schemas ────────────────────────────────────────────────

    RecordActionRequest:
      type: object
      required: [notification_id]
      properties:
        notification_id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [button, reply]
          default: button
          description: Defaults to button unless response_text/reply_text is present.
        action:
          type: string
          description: e.g. "button_pressed" or "reply_sent"; defaults by kind when omitted.
        button_index:
          type: integer
          minimum: 0
          maximum: 3
          description: Required for button actions; ignored for replies.
        button_label:
          type: string
          description: Optional client hint. Server derives the authoritative label from the stored notification buttons.
        response_text:
          type: string
          maxLength: 4096
          description: Required for text replies. Never interpreted as shell input by hook pollers.
        reply_text:
          type: string
          maxLength: 4096
          deprecated: true
          description: Backward-compatible alias for response_text.
        session_id:
          type: string
          format: uuid
          description: Must match the stored notification session when supplied.
        client_event_id:
          type: string
          maxLength: 128
          description: Optional iOS-generated idempotency key; duplicates return the original response.
        metadata:
          type: object
          additionalProperties:
            type: string
          description: Optional safe, non-secret action metadata. Secret-like keys are dropped.

    RecordActionResponse:
      type: object
      required: [id, kind, action, recorded_at, duplicate]
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [button, reply]
        action:
          type: string
        button_index:
          type: integer
          minimum: 0
          maximum: 3
        button_label:
          type: string
        response_text:
          type: string
        recorded_at:
          type: string
          format: date-time
        duplicate:
          type: boolean
          description: True when client_event_id matched a previously recorded response.
        webhook_delivered:
          type: boolean
          description: Whether the project webhook was called

    SimulateDecisionRequest:
      type: object
      required: [notification_id, decision_key]
      properties:
        project_id:
          type: string
          format: uuid
        project:
          type: string
        notification_id:
          type: string
          format: uuid
        decision_key:
          type: string
          description: Structured approval decision key or matching button label.
        device:
          type: string
          description: Test device label surfaced in action metadata.
        client_event_id:
          type: string
          maxLength: 128

    ActionResponseListResponse:
      type: object
      required: [items]
      x-ios-client-required: false
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ActionResponseItem"
        next_cursor:
          type: string
          format: uuid
          description: Cursor for the next page of action/reply responses; omitted on the last page.

    ActionResponseItem:
      type: object
      required: [id, notification_id, project_id, kind, action, ack_status, recorded_at]
      x-ios-client-required: false
      properties:
        id:
          type: string
          format: uuid
        approval_id:
          type: string
          format: uuid
          description: Approval correlation ID when the response came from /v1/approvals.
        notification_id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        session_id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [button, reply]
        action:
          type: string
        decision_key:
          type: string
          description: Structured decision key derived from approval metadata, action text, or button label.
        button_index:
          type: integer
          minimum: 0
          maximum: 3
        button_label:
          type: string
        response_text:
          type: string
          description: User-authored reply text for kind=reply.
        client_event_id:
          type: string
        ack_status:
          type: string
          enum: [pending, processed, ignored, failed]
        acked_at:
          type: string
          format: date-time
        ack_error:
          type: string
          maxLength: 512
        metadata:
          type: object
          additionalProperties:
            type: string
        recorded_at:
          type: string
          format: date-time
        notification_title:
          type: string
          description: Snapshot of the source notification title for local agent context.
        notification_message:
          type: string
          description: Snapshot of the source notification body for local agent context.
        notification_metadata:
          type: object
          additionalProperties:
            type: string
          description: Safe, non-secret metadata snapshot from the source notification.
        notification_created_at:
          type: string
          format: date-time

    AckActionResponseRequest:
      type: object
      x-ios-client-required: false
      properties:
        status:
          type: string
          enum: [processed, ignored, failed]
          default: processed
        error:
          type: string
          maxLength: 512

    AckActionResponseResponse:
      type: object
      required: [id, status]
      x-ios-client-required: false
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [processed, ignored, failed]
        acked_at:
          type: string
          format: date-time
        ack_error:
          type: string

    # ── Admin schemas ────────────────────────────────────────────────

    AdminOverviewResponse:
      type: object
      required: [stats, traffic, recent_activity]
      properties:
        stats:
          $ref: "#/components/schemas/AdminStats"
        traffic:
          $ref: "#/components/schemas/AdminTraffic"
        recent_activity:
          type: array
          items:
            $ref: "#/components/schemas/AdminActivity"

    AdminStats:
      type: object
      required: [total_users, total_projects, active_sessions, messages_today]
      properties:
        total_users:
          type: integer
          format: int64
        total_projects:
          type: integer
          format: int64
        active_sessions:
          type: integer
          format: int64
        messages_today:
          type: integer
          format: int64

    AdminTraffic:
      type: object
      required: [messages_today, messages_last_24h, active_sessions, active_api_keys, registered_devices]
      properties:
        messages_today:
          type: integer
          format: int64
        messages_last_24h:
          type: integer
          format: int64
        active_sessions:
          type: integer
          format: int64
        active_api_keys:
          type: integer
          format: int64
        registered_devices:
          type: integer
          format: int64
        last_message_at:
          type: string
          format: date-time

    AdminActivity:
      type: object
      required: [id, type, title, project_id, project_name, user_id, user_email, created_at]
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
        title:
          type: string
        project_id:
          type: string
          format: uuid
        project_name:
          type: string
        user_id:
          type: string
          format: uuid
        user_email:
          type: string
          format: email
        created_at:
          type: string
          format: date-time

    AdminActivityListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AdminActivity"

    AdminAPIKeysResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AdminAPIKey"

    AdminAPIKey:
      type: object
      required: [id, prefix, scope, user_id, user_email, project_id, project_name, created_at, notification_count]
      properties:
        id:
          type: string
          format: uuid
        prefix:
          type: string
          description: Public API key prefix only; never the secret or hash.
        scope:
          type: string
          enum: [project, account]
          description: Whether the key is bound to one project or the whole user account.
        user_id:
          type: string
          format: uuid
        user_email:
          type: string
          format: email
        project_id:
          type: string
          description: Project UUID for project-scoped keys; empty for account-scoped keys.
        project_name:
          type: string
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
        notification_count:
          type: integer
          format: int64
          description: Project notification count for project keys; account notification count for account keys.
        last_notification_at:
          type: string
          format: date-time

    AdminUsersResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AdminUser"

    AdminUser:
      type: object
      required: [id, email, display_name, tier, created_at, updated_at, project_count, notification_count]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        display_name:
          type: string
        tier:
          type: string
          enum: [free, pro]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        project_count:
          type: integer
          format: int64
        notification_count:
          type: integer
          format: int64
        last_notification_at:
          type: string
          format: date-time

    AdminSettingsResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AdminSetting"

    AdminSetting:
      type: object
      required: [key, value, status]
      properties:
        key:
          type: string
        value:
          type: string
        status:
          type: string
          enum: [configured, missing]
        description:
          type: string

    # ── Crash report schemas ───────────────────────────────────────────

    CrashReportRequest:
      type: object
      required: [reason]
      properties:
        timestamp:
          type: string
          format: date-time
          description: Time the crash was captured; server receipt time is used when omitted.
        reason:
          type: string
          minLength: 1
          description: Crash reason or signal summary captured by the iOS crash reporter.
        stack_trace:
          type: string
          description: Stack trace when safely available; signal crashes may intentionally omit it.
        app_version:
          type: string
          description: CFBundleShortVersionString from the crashing app.
        build_number:
          type: string
          description: CFBundleVersion from the crashing app.
        device_model:
          type: string
          description: Device hardware identifier captured by the app.
        os_version:
          type: string
          description: iOS version captured by the app.
        locale:
          type: string
          description: Locale identifier captured by the app.

    CrashReportResponse:
      type: object
      required: [id, created_at]
      properties:
        id:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time

    CrashReportItem:
      type: object
      required: [id, user_id, timestamp, reason, stack_trace, app_version, build_number, device_model, os_version, locale, created_at]
      properties:
        id:
          type: integer
          format: int64
        user_id:
          type: string
          format: uuid
        timestamp:
          type: string
          format: date-time
        reason:
          type: string
        stack_trace:
          type: string
        app_version:
          type: string
        build_number:
          type: string
        device_model:
          type: string
        os_version:
          type: string
        locale:
          type: string
        created_at:
          type: string
          format: date-time

    AdminCrashReportListResponse:
      type: object
      required: [items, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/CrashReportItem"
        total:
          type: integer

    # ── List schemas ──────────────────────────────────────────────────

    ProjectList:
      type: object
      required: [id, name, created_at]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        sort_order:
          type: integer
        created_at:
          type: string
          format: date-time

    ListListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ProjectList"

    CreateListRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 50

    UpdateListRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 50
        sort_order:
          type: integer
