openapi: 3.1.0
info:
  title: OpenTask API
  version: 1.0.0
  description: |
    REST API for OpenTask, a self-hosted task management application.

    ## Authentication

    All endpoints require authentication via one of the methods below, except
    `/api/health`, `/api/openapi` and `/api/push/vapid-key` (public) and
    `/api/notifications/actions` (takes the API token in its request body):

    - **Bearer token** — `Authorization: Bearer <token>` header
    - **Reverse proxy header** — configured via `OPENTASK_PROXY_AUTH_HEADER` env var
    - **Session cookie** — set by the web UI login flow

    Bearer tokens are created via `POST /api/tokens` or the `db:create-token` CLI command.

    ## Response format

    **Success:** `{ "data": { ... } }`

    **Error:** `{ "error": "message", "code": "ERROR_CODE", "details": { ... } }`

  license:
    name: AGPL-3.0
    url: https://www.gnu.org/licenses/agpl-3.0.html

servers:
  - url: /
    description: Relative to deployment host

security:
  - bearerAuth: []

paths:
  /api/health:
    get:
      summary: Health check
      operationId: getHealth
      tags: [System]
      security: []
      description: Checks that the server is up and can query its database.
      responses:
        '200':
          description: Server is running and the database answers
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
        '503':
          description: The database is unavailable
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [error]
                  message:
                    type: string
                    example: Database unavailable

  /api/openapi:
    get:
      summary: OpenAPI specification
      operationId: getOpenApi
      tags: [System]
      security: []
      responses:
        '200':
          description: OpenAPI 3.1 YAML specification
          content:
            text/yaml:
              schema:
                type: string

  # --- Tasks ---

  /api/tasks:
    get:
      summary: List tasks
      operationId: listTasks
      tags: [Tasks]
      parameters:
        - name: done
          in: query
          schema:
            type: boolean
          description: Filter by completion status (default false)
        - name: project_id
          in: query
          schema:
            type: integer
        - name: label
          in: query
          schema:
            type: string
          description: Filter by label name
        - name: search
          in: query
          schema:
            type: string
          description: Search in title and notes
        - name: trashed
          in: query
          schema:
            type: boolean
        - name: archived
          in: query
          schema:
            type: boolean
        - name: overdue
          in: query
          schema:
            type: boolean
          description: |
            `true` = only what is due right now, by the same definition as the
            app badge, notifications and the snooze-overdue sweep: reminders
            and quotas are never overdue, and a recurring task's due-ness comes
            from its schedule. Only the user's own tasks (the badge's
            population). Applied before pagination.
        - name: kind
          in: query
          schema:
            type: string
            enum: [task, reminder, quota]
          description: |
            Narrow to one population: ordinary tasks, reminders
            (`is_reminder`), or quotas (`is_tracked` or `progress_target > 1`).
            Omitted = all three. Applied before pagination; any other value is
            a 400.
        - name: recurring
          in: query
          schema:
            type: boolean
        - name: one_off
          in: query
          schema:
            type: boolean
        - name: limit
          in: query
          schema:
            type: integer
            default: 200
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: List of tasks
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tasks:
                        type: array
                        items:
                          $ref: '#/components/schemas/Task'
                      count:
                        type: integer
                        description: Number of tasks in this page
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create task
      operationId: createTask
      tags: [Tasks]
      description: |
        Send a raw title string for AI enrichment, or provide structured fields
        to bypass enrichment. AI enrichment triggers when only `title` is provided
        (no due_at, priority=0, no labels, no rrule).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskCreate'
      responses:
        '201':
          description: Created task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/tasks/counts:
    get:
      summary: Counts for the nav badges
      operationId: getTaskCounts
      tags: [Tasks]
      description: |
        `{ total, overdue, today }` over the user's active tasks — the same
        numbers the Tasks page shows in its top bar, computed with the same
        function, for pages that do not load the list.
      responses:
        '200':
          description: Task counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total:
                        type: integer
                      overdue:
                        type: integer
                        description: due_at in the past.
                      today:
                        type: integer
                        description: due_at inside the user's local day.
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/tasks/{id}:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    get:
      summary: Get task
      operationId: getTask
      tags: [Tasks]
      responses:
        '200':
          description: Task details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      summary: Update task
      operationId: updateTask
      tags: [Tasks]
      description: PATCH semantics — only fields present in the body are updated.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskUpdate'
      responses:
        '200':
          description: Updated task with change metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Task'
                      - type: object
                        properties:
                          fields_changed:
                            type: array
                            items:
                              type: string
                          description:
                            type: string
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      summary: Delete task (soft delete to trash)
      operationId: deleteTask
      tags: [Tasks]
      responses:
        '200':
          description: Trashed task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Task'
                      - type: object
                        properties:
                          message:
                            type: string
                            example: Task moved to trash
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/done:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Mark task done
      operationId: markTaskDone
      tags: [Tasks]
      description: |
        For recurring tasks, advances due_at to the next occurrence.
        For one-off tasks, sets done=true and archives.

        **Quotas** are refused (400) unless the body has `close_period: true`.
        Completing a quota closes its period early and resets
        `progress_current` to 0; to log one, use
        `POST /api/tasks/{id}/progress`. The body is optional — a bare POST
        with no body works for everything else.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                close_period:
                  type: boolean
                  description: Deliberately complete a quota, closing its period early.
      responses:
        '200':
          description: Completed task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Task'
                      - type: object
                        properties:
                          was_recurring:
                            type: boolean
                          next_due_at:
                            type: [string, 'null']
                            format: date-time
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/undone:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Mark task undone (reopen / put back)
      operationId: markTaskUndone
      tags: [Tasks]
      description: |
        One-off task: reopens it. Recurring task: puts back its most recent
        completion — the occurrence returns to the due date it had and the
        completion counts roll back one. Refuses (400) when the task's due date
        has changed since that completion, or when there is nothing to put back.
        Logged as an undoable action either way.

        Refuses (400) a quota (2026-09-24): a quota's completions are the period
        rollover's record of a met period, and putting one back deleted that
        record while the period stayed closed. Correct a count with
        `POST /api/tasks/{id}/progress` and `delta: -1` instead.
      responses:
        '200':
          description: Reopened task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/progress:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Log progress on a tracked task
      operationId: incrementTaskProgress
      tags: [Tasks]
      description: |
        Records progress on a task with `progress_target` > 1 (REDESIGN-V03 §5).

        A sub-target increment is NOT a completion: it dispatches
        `task.progressed`, never `task.completed`, and the task stays open until
        its period boundary. Returns 400 if the task is not tracked.

        If the quota's period has ended but the rollover job has not run yet
        (it runs every few minutes), the period is closed first, so a +1 just
        after midnight counts for the new day. Progress logged today is also
        recorded in the quota's `quota_day_state` (it clears a weekly/monthly
        quota prompt for the day); undo restores both.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                delta:
                  type: integer
                  default: 1
                  description: >
                    Defaults to +1. A negative value corrects a mis-log;
                    progress never goes below zero.
                source:
                  $ref: '#/components/schemas/ProgressSource'
      responses:
        '200':
          description: Updated task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Task'
                      - type: object
                        properties:
                          met:
                            type: boolean
                            description: Whether progress has reached the target
                          description:
                            type: string
                            example: '3/2'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/quota-stats:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    get:
      summary: A quota's record over its closed periods
      operationId: getQuotaStats
      tags: [Tasks]
      description: |
        How often a quota has been met: its closed periods that reached the
        target and those that fell short, and the run of met periods. Counted
        from the periods the rollover has recorded, so the period in progress
        is not included in any of the four numbers. Returns 400 if the task is
        not a quota.

        `last_logged_at` (2026-10-16) is when the last tap that still stands
        was logged: walking the progress events newest first, each negative
        event (a −1, an undo, a put-back) cancels the latest +1 before it.
        Null when none stands.
      responses:
        '200':
          description: The quota's stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required:
                      [periods_met, periods_missed, current_streak, longest_streak, last_logged_at]
                    properties:
                      periods_met:
                        type: integer
                        description: Closed periods that reached the target
                      periods_missed:
                        type: integer
                        description: Closed periods that fell short of it
                      current_streak:
                        type: integer
                        description: Met periods in a row, counting back from the most recent closed one
                      longest_streak:
                        type: integer
                        description: The longest run of met periods in a row
                      last_logged_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: When the last tap still standing was logged (UTC)
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/progress-events:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    get:
      summary: A quota's progress history
      operationId: getProgressEvents
      tags: [Tasks]
      description: |
        Every +1 and −1 logged against a quota, newest first, with where it
        was logged from (`source`). An undo of a count writes the opposite
        event (`source: undo`) and a redo writes it again (`source: redo`), so
        the events always sum to the count's movement. Owner or shared-project
        access, as for the task. At most `limit` rows (default and maximum
        500); when `has_more` is true, pass the last row's `id` as `before_id`
        for the next page. Returns 400 if the task is not a quota.
      parameters:
        - name: since
          in: query
          description: Only events logged at or after this time (ISO 8601)
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          description: Only events logged before this time (ISO 8601)
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 500
        - name: before_id
          in: query
          description: Only events older than this event id (paging)
          schema:
            type: integer
      responses:
        '200':
          description: The events
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [events, has_more]
                    properties:
                      events:
                        type: array
                        items:
                          type: object
                          required: [id, delta, logged_at, source, user_id]
                          properties:
                            id:
                              type: integer
                            delta:
                              type: integer
                              description: Signed change to the count
                            logged_at:
                              type: string
                              format: date-time
                            source:
                              type: string
                              enum: [web, widget, watch, mac, api, undo, redo, unknown]
                              description: Where it was logged from; `unknown` for events logged before sources were recorded (2026-10-16)
                            user_id:
                              type: integer
                              description: Who logged it
                      has_more:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not the caller's quota (and not in a shared project)
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/skip-occurrence:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Skip this occurrence without completing it
      operationId: skipTaskOccurrence
      tags: [Tasks]
      description: |
        "Not doing this occurrence - advance without recording a completion"
        (REDESIGN-V03 §7.5).

        Recurring tasks advance to the next occurrence with `completion_count`
        untouched and `skip_count` incremented. One-off tasks are archived
        without being marked done. Dispatches `task.skipped`, never
        `task.completed`.

        Named `skip-occurrence` rather than `skip` because the review API
        already uses "skip" for a no-op review acknowledgment.

        Reminders (`is_reminder`) are refused (400): a missed one already
        rolls forward on its own, and completing one marks it considered.
      responses:
        '200':
          description: Skipped task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Task'
                      - type: object
                        properties:
                          was_recurring:
                            type: boolean
                          description:
                            type: string
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/confirm:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Confirm an assistant-proposed task
      operationId: confirmTaskProvenance
      tags: [Tasks]
      description: |
        Swaps the `ai-proposed` provenance label for `ai-added` (REDESIGN-V03
        §7.2). Task-scoped and narrow: confirming is a statement about where a
        task came from, so nothing else is modified. Idempotent.
      responses:
        '200':
          description: Confirmed task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Task'
                      - type: object
                        properties:
                          confirmed:
                            type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/labels:
    get:
      summary: List registered labels
      operationId: listLabels
      tags: [Labels]
      description: |
        The label registry (REDESIGN-V03 §7.2). Labels used to be bare
        free-text, so a typo silently forked the taxonomy. Creating a label is
        now a discrete act: a task write carrying an unknown label is rejected
        unless it also carries `create_label`.
      responses:
        '200':
          description: Registered labels
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      labels:
                        type: array
                        items:
                          $ref: '#/components/schemas/Label'
                      count:
                        type: integer
    post:
      summary: Register a label
      operationId: createLabel
      tags: [Labels]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                facet:
                  type: string
                  enum: [domain, operational]
                  default: domain
                  description: >
                    Chip semantics are AND across facets, OR within a facet.
                    `operational` labels carry behavior; `domain` labels carry
                    meaning.
                icon:
                  type: [string, 'null']
                color:
                  type: [string, 'null']
      responses:
        '200':
          description: The registered label (idempotent - returns the existing row if present)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Label'

  /api/labels/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
    delete:
      summary: Deregister a label
      operationId: deleteLabel
      tags: [Labels]
      description: |
        Removes the label from the registry. Does NOT strip it from tasks that
        already carry it - §7.2 checks only NEWLY added labels, so those tasks
        stay editable; the label simply can't be applied to anything new.
      responses:
        '200':
          description: Deleted
        '404':
          description: Label not found

  /api/time-slots:
    get:
      summary: List time slots
      operationId: listTimeSlots
      tags: [Time slots]
      description: |
        Life-moment containers - "Early morning", "Before work", "Evening"
        (REDESIGN-V03 §6.0). The dashboard and the Reminders surface group by
        this same table, so "morning" means one thing across the app.

        An item belongs to the slot with the latest `start_time` <= its time of
        day, where time of day is `anchor_time` if set, else `due_at` read in
        the user's timezone.
      responses:
        '200':
          description: Time slots, earliest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      time_slots:
                        type: array
                        items:
                          $ref: '#/components/schemas/TimeSlot'
                      count:
                        type: integer
    post:
      summary: Create a time slot
      operationId: createTimeSlot
      tags: [Time slots]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, start_time]
              properties:
                label:
                  type: string
                start_time:
                  type: string
                  pattern: '^([01]\\d|2[0-3]):([0-5]\\d)$'
                  description: HH:MM, 24-hour, local
                sort_order:
                  type: integer
                  default: 0
                  description: Vestigial - slots are always ordered by start_time.
      responses:
        '200':
          description: The created slot (start_time must be unique among the user's slots)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimeSlot'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/time-slots/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Time slot ID. Another user's slot is a 404, like a missing one.
    patch:
      summary: Rename a time slot and/or move its start
      operationId: updateTimeSlot
      tags: [Time slots]
      description: |
        Settings → Reminder periods. A reminder belongs to a slot only through
        its time of day, so moving a slot's `start_time` also moves that slot's
        reminders to keep them in it: reminders on the old start move to the new
        start, reminders inside the slot keep their time unless the new
        boundaries would put them in another slot (then they move to the new
        start). Other slots' reminders and ordinary tasks are never touched.
        The slot change and the reminder moves are one atomic undo entry
        (`time_slot_edit`) that also restores the slot; `undo_id` names it.
        A rename alone moves nothing and returns `undo_id: null`.

        `start_time` must be unique among the user's slots. There is no
        reordering: slots are always ordered by `start_time`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              properties:
                label:
                  type: string
                  minLength: 1
                  maxLength: 100
                start_time:
                  type: string
                  pattern: '^([01]\\d|2[0-3]):([0-5]\\d)$'
                  description: HH:MM, 24-hour, local
      responses:
        '200':
          description: The slot after the change
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimeSlotChange'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      summary: Remove a time slot
      operationId: deleteTimeSlot
      tags: [Time slots]
      description: |
        Removes the slot and moves each of its reminders to the start of the
        remaining slot nearest the reminder's own time of day (a tie goes to the
        earlier slot), so none drop out of every slot. Quota prompts placed in
        the slot move the same way (2026-09-25): every stored prompt period
        naming it — a quota's `quota_prompt_config.slot_id` and per-number
        overrides, and the user's default prompt period — is repointed to the
        remaining slot nearest the removed slot's start (`quotas_moved`). The
        last remaining slot cannot be removed (400). Always an undo entry
        (`time_slot_delete`, `undo_id`): undo restores the slot under its
        original id together with its reminders and prompt placements.
      responses:
        '200':
          description: The removed slot, as it was
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TimeSlotChange'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/snooze:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Snooze task
      operationId: snoozeTask
      tags: [Tasks]
      description: |
        Moves a task's target (`due_at`) — the same move as `PATCH { target_at }`
        (2026-10-12, spec §6: there is no snooze state). It stops at the task's
        deadline while that deadline is still ahead (spec §8.2): the response's
        `capped_at_deadline` is true when it did. Once the deadline has passed
        the cap is off and any target can be picked (2026-10-12).
        `allow_past_deadline` and `cap_at_deadline` are accepted from
        old clients and change nothing. Two populations refuse it with a 400,
        because neither has a due date to move: reminders (§6), which stay in
        their time slot until completed, and quotas (§5), which are counted
        within a period rather than due on a day. The bulk snooze sweep skips
        both instead of failing.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [until]
              properties:
                until:
                  type: string
                  format: date-time
                  description: ISO 8601 datetime to snooze until
      responses:
        '200':
          description: Snoozed task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Task'
                      - type: object
                        properties:
                          previous_due_at:
                            type: [string, 'null']
                            format: date-time
                          original_due_at:
                            type: [string, 'null']
                            format: date-time
                          description:
                            type: [string, 'null']
                          capped_at_deadline:
                            type: boolean
                            description: The move stopped AT the deadline instead of `until`.
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/restore:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Restore task from trash
      operationId: restoreTask
      tags: [Tasks]
      responses:
        '200':
          description: Restored task
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/tasks/{id}/reprocess:
    parameters:
      - $ref: '#/components/parameters/TaskId'
    post:
      summary: Retry AI enrichment
      operationId: reprocessTask
      tags: [Tasks]
      description: Re-runs AI enrichment using the original_title.
      responses:
        '200':
          description: Task queued for reprocessing
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # --- Bulk Operations ---

  /api/tasks/bulk/done:
    post:
      summary: Mark multiple tasks done
      operationId: bulkDone
      tags: [Bulk Operations]
      description: |
        Quotas in `ids` are skipped (counted in `quota_skipped`) unless
        `close_period: true` — completing a quota closes its period early and
        resets its count. A batch of nothing but quotas is a 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                close_period:
                  type: boolean
                  description: Also complete quotas in the batch, closing each period early.
      responses:
        '200':
          description: Bulk operation result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResult'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/tasks/bulk/snooze:
    post:
      summary: Snooze multiple tasks
      operationId: bulkSnooze
      tags: [Bulk Operations]
      description: |
        Snoozes one or more tasks. Supports absolute mode (`until`) or relative
        mode (`delta_minutes`) — exactly one must be provided.

        Priority filtering, by default:

        - P0-P2 are always snoozed.
        - P3 (High) is snoozed ONLY when no lower-priority task in the same
          batch is still eligible. A mixed batch clears P0-P2 and leaves the
          High tasks; the next call, finding only High left, takes them. A P4 in
          the batch does not hold this back, since a P4 is never swept.
        - P4 (Urgent) is never snoozed by a sweep — its due date is a hard
          deadline and every change to one has to be deliberate.
        - A task whose DEADLINE HAS PASSED is read as at least P3 (2026-10-12):
          it waits while any P0-P2 task without a passed deadline is still
          eligible, and goes with the High tier after. A P3 with a passed
          deadline is still P3; a P4 is still never swept. Those held back
          only by the deadline are counted in `skipped_deadline` (not in
          `skipped_urgent`).

        Callers that represent an explicit user selection (e.g., the mobile
        selection sheet's quick panel) should pass `include_task_ids` so the
        listed high/urgent tasks are snoozed anyway.

        `skipped_urgent` keeps its name for client compatibility and counts BOTH
        P3 and P4 skips. `skipped_high` is the P3 subset of it, so a client can
        name the two tiers accurately; Urgent alone is the difference.

        Each eligible task is changed exactly as `PATCH /api/tasks/{id}` with
        `{ due_at }` would change it: a dated task's target moves (its
        first-target date kept), stopping at a deadline still ahead
        (`capped_deadline`; a passed deadline caps nothing); a
        task with no date (absolute mode only) gets its
        first date, which is not counted as a snooze; a task already due at
        the target is left alone. `tasks_affected` counts only the tasks whose
        date actually moved, so it plus `tasks_skipped` can be less than the
        number of `ids`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                until:
                  type: string
                  format: date-time
                  description: Absolute target time (mutually exclusive with delta_minutes)
                delta_minutes:
                  type: integer
                  description: Relative snooze in minutes (mutually exclusive with until)
                include_task_ids:
                  type: array
                  items:
                    type: integer
                  description: |
                    Task IDs to snooze even if they are P4/Urgent. Used by
                    explicit user selections to bypass the default urgent skip.
                allow_past_deadline_ids:
                  type: array
                  items: { type: integer }
                  deprecated: true
                  description: 'Accepted from old clients and ignored since 2026-10-12: every move stops at its deadline (spec §8.2).'
                cap_at_deadline_ids:
                  type: array
                  items: { type: integer }
                  deprecated: true
                  description: 'Accepted from old clients and ignored since 2026-10-12: every move stops at its deadline (spec §8.2).'
      responses:
        '200':
          description: Bulk operation result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResult'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/tasks/bulk/snooze-overdue:
    post:
      summary: Snooze all overdue tasks
      operationId: bulkSnoozeOverdue
      tags: [Bulk Operations]
      description: |
        Snoozes all overdue tasks that the priority rule allows: P0-P2 always,
        P3 (High) once no lower-priority task in the batch is still eligible,
        and P4 (Urgent) never. See `/api/tasks/bulk/snooze` for the full rule
        and for what `skipped_urgent` and `skipped_high` count.

        The target is at most one of `until`, `delta_minutes`, `slot` or
        `tomorrow`; with none, the user's default snooze option applies.

        **Feedback notification.** When the call is authenticated with an API
        token (not a browser session) and the sweep leaves High or Urgent tasks
        overdue, the server sends one quiet push notification to the user's
        devices saying how many of each stayed overdue (counts only, never
        titles). It is not sent when nothing High or Urgent is left, when the
        user has turned off `sweep_feedback_notifications_enabled` or
        notifications as a whole, or when the request says `notify: false`.
        A newer result replaces the older notification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                until:
                  type: string
                  format: date-time
                  description: Absolute snooze target.
                delta_minutes:
                  type: integer
                  minimum: 1
                  maximum: 525600
                  description: Minutes from now (60 or more snaps to the hour).
                slot:
                  type: string
                  pattern: '^(([01][0-9]|2[0-3]):[0-5][0-9]|next)$'
                  description: >-
                    A time slot to snooze to, resolved in the user's timezone:
                    a slot's start time ("HH:MM") snoozes to its next start
                    (today if still ahead, otherwise tomorrow); "next" snoozes
                    to the soonest upcoming slot start. 400 if "next" and the
                    user has no time slots.
                tomorrow:
                  type: boolean
                  enum: [true]
                  description: Tomorrow at the user's morning time.
                include_task_ids:
                  type: array
                  maxItems: 10
                  items:
                    type: integer
                  description: Tasks to include even if not currently due (e.g. the P4 being acted on).
                allow_past_deadline_ids:
                  type: array
                  items: { type: integer }
                  deprecated: true
                  description: 'Accepted from old clients and ignored since 2026-10-12: every move stops at its deadline (spec §8.2).'
                notify:
                  type: boolean
                  default: true
                  description: >-
                    `false` suppresses the feedback notification for this call,
                    for a caller that shows the result itself (e.g. a Shortcut
                    that displays `message`).
      responses:
        '200':
          description: Bulk operation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tasks_affected:
                        type: integer
                      tasks_skipped:
                        type: integer
                      skipped_urgent:
                        type: integer
                        description: Skipped on priority — High (P3) AND Urgent (P4).
                      skipped_high:
                        type: integer
                        description: The High subset of `skipped_urgent`; swept on the next press.
                      snoozed_high:
                        type: integer
                        description: How many of the moved tasks were High.
                      skipped_reminders:
                        type: integer
                        description: Reminders left in place (reminders are period-locked).
                      skipped_deadline:
                        type: integer
                        description: Held back like High because their deadline has passed (2026-10-12) — below P3 by priority, swept once nothing lower is left. Not in `skipped_urgent`.
                      capped_deadline:
                        type: integer
                        description: Moved only as far as their deadline (2026-10-12, spec §8.2). Also counted in `tasks_affected`.
                      until:
                        type: string
                        format: date-time
                        description: The resolved snooze target.
                      message:
                        type: string
                        description: >-
                          Ready-to-display summary, the same copy as the web toast — e.g.
                          "Snoozed 3 tasks (2 high, 1 urgent skipped)" or "No snoozable
                          tasks". For clients that show the result without branching on
                          the counts (Apple Shortcuts).
                      feedback_notification:
                        type: boolean
                        description: >-
                          Whether this call sent the feedback notification (see
                          above). A Shortcut can show `message` only when this is
                          false.
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/tasks/bulk/edit:
    post:
      summary: Edit multiple tasks
      operationId: bulkEdit
      tags: [Bulk Operations]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids, changes]
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                changes:
                  type: object
                  properties:
                    priority:
                      type: integer
                      minimum: 0
                      maximum: 4
                    labels:
                      type: array
                      items:
                        type: string
                    project_id:
                      type: integer
                    target_at:
                      type: [string, 'null']
                      format: date-time
                      description: >-
                        The new target for every task (`due_at` is the deprecated alias;
                        `null` removes the target — on a one-off with a deadline it puts
                        the deadline's automatic target back). Every move stops at each
                        task's deadline while it is ahead (counted in `capped_deadline`).
                        As a snooze, a passed deadline holds a task to the High tier
                        (`skipped_deadline`).
                    deadline_at:
                      type: [string, 'null']
                      format: date-time
                    horizon:
                      type: [string, 'null']
                      enum: [days, weeks, months, eventually, null]
                    deadline_nag_off:
                      type: boolean
                      deprecated: true
                      description: Accepted from old clients and ignored since 2026-10-12 (spec §8).
                    affirm_horizon:
                      type: boolean
                      description: Keep each task's horizon and stamp `growth_kept_at` (Process's "keep"; it asks again a threshold later).
                    quota_prompt_config:
                      type: [object, 'null']
                      description: >-
                        Unlike a single-task PATCH (which replaces the whole config), a bulk
                        edit MERGES this over each task's stored config, top level only:
                        `enabled` and `slot_id` replace that field, `numbers` replaces the
                        per-number map as a unit and an empty map `{}` clears it. Fields not
                        sent are kept per task. `null` clears the config on every task. Slot
                        ids must be the owner's periods (400 otherwise).
                      properties:
                        enabled:
                          type: boolean
                        slot_id:
                          type: [integer, 'null']
                        numbers:
                          type: object
                          additionalProperties:
                            type: [integer, 'null']
                per_task:
                  type: object
                  description: >-
                    Per-task values merged over `changes`, keyed by task id. Lets one
                    request give each task its own recurrence rule (e.g. moving several
                    reminders to another time slot while each keeps its own days), as a
                    single undoable action.
                  additionalProperties:
                    type: object
                    properties:
                      rrule:
                        type: [string, 'null']
                include_task_ids:
                  type: array
                  items:
                    type: integer
                  description: >-
                    Task ids that bypass the High/Urgent snooze filter when `changes.due_at`
                    moves a date without changing `rrule` (explicit picks), as on
                    bulk/snooze. Reminders and quotas are never given a date.
                allow_past_deadline_ids:
                  type: array
                  items: { type: integer }
                  deprecated: true
                  description: 'Accepted from old clients and ignored since 2026-10-12: every move stops at its deadline (spec §8.2).'
                cap_at_deadline_ids:
                  type: array
                  items: { type: integer }
                  deprecated: true
                  description: 'Accepted from old clients and ignored since 2026-10-12: every move stops at its deadline (spec §8.2).'
                delta_minutes:
                  type: integer
                  minimum: -1440
                  maximum: 525600
                  description: >-
                    Relative date move — each task's own `due_at` plus this many minutes.
                    Mutually exclusive with `changes.due_at`. A task with no due date
                    keeps the rest of the edit and is counted in `skipped_no_due_date`.
                date_task_ids:
                  type: array
                  items:
                    type: integer
                  description: >-
                    Apply the date part (`changes.due_at` / `delta_minutes`) only to these
                    ids; every other field in `changes` still applies to all `ids`. The
                    whole request is one undo entry. A task the date cannot apply to (a
                    quota, one the High/Urgent filter holds back, or an undated task in a
                    relative move) still takes the other fields; one left with nothing
                    to change is skipped.
      responses:
        '200':
          description: Bulk operation result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResult'
        '400':
          description: >
            Validation error. Includes a batch that would leave any quota without
            a period (e.g. `rrule: null` over a selection holding a quota) — the
            whole batch is refused and nothing is written.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/tasks/bulk/delete:
    post:
      summary: Soft-delete multiple tasks
      operationId: bulkDelete
      tags: [Bulk Operations]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items:
                    type: integer
      responses:
        '200':
          description: Bulk operation result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResult'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/tasks/bulk/complete:
    post:
      summary: Complete multiple tasks in one request
      operationId: bulkComplete
      tags: [Bulk Operations]
      description: |
        Built for the §6.1 notification batch checklist: the iOS content
        extension commits N staged check-marks with one request. Delegates to
        the same core bulk-done path — one transaction, ONE undo entry.

        Quotas are skipped unless `close_period: true`, as for
        `/api/tasks/bulk/done` (and refused when nothing else is in the batch).

        Quota prompts (2026-09-24): `prompts` acts on quota prompts in the SAME
        transaction and undo entry — a checklist commit or a slot's "Considered
        all" mixing reminders and prompts is one Undo. Each entry is a
        `prompt_key` string (consider it) or `{ key, did }` (`did: true` = the
        square checkbox: log progress and consider it). A quota is acted on
        through its prompt, never completed by id. `ids` may be empty when
        `prompts` is not. A stale-date or foreign key refuses the whole batch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                close_period:
                  type: boolean
                  description: Also complete quotas in the batch, closing each period early.
                prompts:
                  type: array
                  maxItems: 500
                  items:
                    oneOf:
                      - type: string
                        description: A prompt_key to consider.
                      - $ref: '#/components/schemas/PromptAction'
                source:
                  $ref: '#/components/schemas/ProgressSource'
      responses:
        '200':
          description: Completion result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tasks_affected:
                        type: integer
                      recurring_count:
                        type: integer
                      one_off_count:
                        type: integer
                      quota_skipped:
                        type: integer
                        description: Quotas left alone because close_period was not set.
                      prompts_considered:
                        type: integer
                        description: Prompts considered (no progress) in this batch.
                      prompts_did:
                        type: integer
                        description: Prompts done ("did it") in this batch.
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Quota prompts (quota reminders, 2026-09-24) ---

  /api/quota-prompts/consider:
    post:
      summary: Consider quota prompts (no progress)
      operationId: considerQuotaPrompts
      tags: [Reminders]
      description: |
        The round circle on a quota prompt: handled for today, nothing logged.
        One transaction and one undo entry for every key; idempotent. Keys come
        from `GET /api/reminders` `groups[].prompts[].prompt_key`; a key for
        another local day, or for a quota that is not the caller's own, refuses
        the whole request (400).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PromptKeys'
      responses:
        '200':
          $ref: '#/components/responses/PromptActionResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/quota-prompts/did:
    post:
      summary: Did it — log progress on quota prompts
      operationId: didQuotaPrompts
      tags: [Reminders]
      description: |
        The square checkbox on a quota prompt: logs progress AND considers the
        prompt. Idempotent per key, so a double tap or a retry logs once: a
        daily quota's prompt `k` raises today's count to at least `k`; any
        other quota gets +1 unless that key was already done today. One
        transaction, one undo entry; undo restores the count and the prompt.
        Dispatches `task.progressed` for each quota whose count moved.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PromptKeys'
      responses:
        '200':
          $ref: '#/components/responses/PromptActionResult'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/quota-prompts/restore:
    post:
      summary: Put back handled quota prompts
      operationId: restoreQuotaPrompts
      tags: [Reminders]
      description: |
        The filled dashed circle on a handled prompt in a "considered" / DONE
        list (2026-09-25): the prompt waits for today again. Its key leaves
        today's `considered` and `did`; if it was "did it", exactly the
        progress that did-it added is taken back (`quota_day_state.did_applied`
        — a daily prompt `k` done when the count was already at `k` added 0,
        so it takes back 0). Any other did-it the lower count no longer
        supports waits again too. A key that is already waiting changes
        nothing. One transaction and one undo entry (always logged, so a
        toast's Undo never reaches an older action). Keys for another local
        day, or for a quota that is not the caller's own, refuse the whole
        request (400). Quotas are never put back with `/api/tasks/{id}/undone`.
        Dispatches `task.progressed` for each quota whose count moved.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PromptKeys'
      responses:
        '200':
          description: How many prompts now wait again, and the quotas touched
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      restored:
                        type: integer
                        description: Keys that were handled and now wait again
                      tasks:
                        type: array
                        items:
                          $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Quotas (§5) ---

  /api/quotas:
    get:
      summary: Every open quota for the user
      operationId: listQuotas
      tags: [Quotas]
      description: |
        The Quotas surface. A quota is "how often", not "when" — four workouts a
        week, one date night a month — and is a task carrying `progress_target`
        greater than 1 OR `is_tracked` true. The flag exists so a target of one
        can still be a quota rather than a deadline.

        A quota has NO due date (since 2026-09-08): `due_at` and
        `original_due_at` are always null, creating or updating one with a date
        is a 400, and snoozing one is a 400. "Four times this week" cannot be
        late on a Tuesday. The period a quota counts within is named by its
        rrule's FREQ, and the current period's start is `progress_period_start`
        on the task — that field is the period anchor; use it, not `due_at`, for
        pace.

        A quota is also absent from `GET /api/tasks`-backed list surfaces in the
        app — it appears on this endpoint and in the Track panel and nowhere
        else — but `GET /api/tasks` itself still returns quotas, so clients that
        build their own Track view from the full corpus keep working.

        Order is deliberately NOT specified here. Clients sort quotas
        alphabetically so that logging progress on one never reorders the others
        under the user's finger.
      responses:
        '200':
          description: The user's open quotas
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      quotas:
                        type: array
                        items:
                          $ref: '#/components/schemas/Task'
                      total:
                        type: integer
                      has_any:
                        type: boolean
                        description: |
                          Whether the user has any quota at all, including done
                          ones. Lets a client tell "none open" apart from "has
                          never made one" when `quotas` is empty.
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Reminders (§6) ---

  /api/task-segments/consider:
    post:
      summary: Mark task-segment rows considered
      operationId: taskSegmentConsider
      tags: [Reminders]
      description: >
        The circle: stamps `last_considered_at`. Never a completion. One undo entry; logged even when already considered today. The caller's own open tasks only; anything else refuses the
        whole request (400).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: integer }
      responses:
        '200':
          description: Done
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      considered: { type: integer }
  /api/task-segments/defer:
    post:
      summary: Defer task-segment rows to tomorrow
      operationId: taskSegmentDefer
      tags: [Reminders]
      description: >
        Defer All: stamps `segment_deferred_at`; off today's segment, back tomorrow. Not a consider. One undo entry. The caller's own open tasks only; anything else refuses the
        whole request (400).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: integer }
      responses:
        '200':
          description: Done
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deferred: { type: integer }
  /api/task-segments/restore:
    post:
      summary: Put task-segment rows back
      operationId: taskSegmentRestore
      tags: [Reminders]
      description: >
        The put-back behind "Show N considered" (2026-10-11): clears `last_considered_at` and `segment_deferred_at`, so each row waits in its segment again today and comes back daily until it is considered. One undo entry. The caller's own open tasks only; anything else refuses the
        whole request (400).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: integer }
      responses:
        '200':
          description: Done
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      restored: { type: integer }
  /api/task-segments/target-today:
    post:
      summary: Target tasks today
      operationId: taskSegmentTargetToday
      tags: [Reminders]
      description: >
        A move of the target (a target the user picked) to the Snooze-all button's time while still today — the next period or the default snooze option — else the top of the next hour; or to `at`, when given. Also marks them considered and stamps `segment_targeted_at`. One undo entry (bulk edit) puts back the target and both stamps. Like every move it stops at the task's deadline (2026-10-12, spec §8.2). The caller's own open tasks only; anything else refuses the
        whole request (400). REPEATING tasks (an `rrule`) are targeted too since 2026-10-12 (spec §6): only the target moves, the scheduled occurrence (`occurrence_at`) stays, so done that day brings the next occurrence round on schedule.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: integer }
                at:
                  type: string
                  format: date-time
                  description: >
                    Optional (2026-10-12): the moment to target instead of the computed one — what the
                    Today button's long-press / right-click picker sends. Any ISO 8601 datetime, stored as
                    UTC. Must fall on today or a later day in the user's timezone (an earlier time today is
                    allowed); a past day, or a string that isn't a date, is a 400.
      responses:
        '200':
          description: Done
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      until: { type: string, format: date-time }
                      affected: { type: integer }
                      skipped_recurring:
                        type: array
                        items: { type: integer }
                        description: Always empty since 2026-10-12 (repeating tasks are targeted too); kept for older clients.
  /api/process:
    get:
      summary: What Process works through
      operationId: getProcess
      tags: [Tasks]
      description: >
        The in-app Process button's lists, computed by rule (no AI, so it works with AI off). Read-only:
        Process writes through PATCH, bulk edit (`affirm_horizon`, `target_at: null` — on a deadline
        task that is Back to auto), target-today and DELETE. Only the caller's own open tasks, never
        reminders or quotas; `growth` and the stale-target lists also leave out recurring tasks.

        - `inbox`: tasks still in the Inbox project, oldest first, recurring ones included (the
          dashboard's Inbox group, so the two counts agree).
        - `growth`: tasks long past their horizon, longest first. Past the age badge's thresholds
          (one set, spec §7): days over 7 days, weeks or unset over 1 month, months over 1 quarter
          (eventually never), measured from the latest of `horizon_set_at` (else `created_at`) and
          `growth_kept_at` (the last "keep": it asks again one threshold after it). Asks for the
          next horizon up. Excludes tasks whose target has arrived (they are in the old-target
          lists or on the dashboard) and tasks whose deadline is close enough to steer the horizon (90 days
          or past). `from` is null for an unset horizon. `can_act` (priority 2+) offers "do it" / "raise
          priority" beside "grow".
        - `stale_targets`: tasks over target now with 3 or more attempts (`attempts`: local days
          over target, spec §7 — a move keeps them), most first. Offered for removal, never
          automatic.
        - `stale_targets_with_deadline`: the same for tasks with a deadline, listed apart so a client's
          nag is never stopped by default.
      responses:
        '200':
          description: Process lists
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      inbox_project_id: { type: [integer, 'null'] }
                      inbox:
                        type: array
                        items: { $ref: '#/components/schemas/Task' }
                      growth:
                        type: array
                        items:
                          type: object
                          properties:
                            task: { $ref: '#/components/schemas/Task' }
                            from:
                              type: [string, 'null']
                              enum: [days, weeks, months, null]
                            to:
                              type: string
                              enum: [weeks, months, eventually]
                            days_on_horizon: { type: integer }
                            can_act: { type: boolean }
                      stale_targets:
                        type: array
                        items: { $ref: '#/components/schemas/ProcessStaleTarget' }
                      stale_targets_with_deadline:
                        type: array
                        items: { $ref: '#/components/schemas/ProcessStaleTarget' }
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/reminders:
    get:
      summary: Today's incomplete reminders grouped by time slot
      operationId: listReminders
      tags: [Reminders]
      description: |
        The Reminders surface (prompted thoughts, not actions). Items carry no
        debt: never counted in overdue, never in the badge, never notified
        individually — the time slot notifies. A reminder completed today
        drops out of its group and returns with its next occurrence.

        QUOTA PROMPTS (2026-09-24). Every unmet quota also appears as a prompt
        in a period each day, in the SEPARATE `groups[].prompts` array.
        `reminders`, `count`, `considered`, `total` and `considered_total` stay
        reminder-only, exactly as before, so clients that predate prompts are
        unaffected. `prompts` holds every prompt assigned to the slot today,
        handled ones included; a prompt is waiting while `!considered &&
        !done`, and a client counts it like a reminder (waiting, then
        considered). Act on one with `POST /api/quota-prompts/consider`,
        `/did`, or `prompts` in `POST /api/tasks/bulk/complete`. The user's
        `quota_prompts_enabled` preference and the server's
        `OPENTASK_QUOTA_PROMPTS=off` each make every `prompts` array empty.
        The slot notification and its nags count waiting prompts too, so a
        client's slot checklist should list them after the reminders.
      responses:
        '200':
          description: Reminder groups in slot order
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total:
                        type: integer
                      considered_total:
                        type: integer
                        description: Reminders considered today, all slots.
                      not_today:
                        type: array
                        description: Reminders with no occurrence today (e.g. a weekly one on its off day), so they stay reachable. Not counted in total.
                        items:
                          $ref: '#/components/schemas/Task'
                      prompts_total:
                        type: integer
                        description: Quota prompts still waiting today, all slots.
                      prompts_considered_total:
                        type: integer
                        description: Quota prompts handled today, all slots.
                      task_segments:
                        type: array
                        description: |
                          Task segments (2026-10-10): one-off tasks with no
                          target of their own, one segment per horizon (days
                          daily; weeks on the user's `check_in_day`, Saturday
                          by default; months the first check-in day of the
                          month; eventually the first one of Jan, Apr, Jul,
                          Oct) — plus UPCOMING (2026-10-12, `horizon:
                          "upcoming"`, after `days`): targeted one-offs and
                          repeating tasks' next occurrences on their heads-up
                          days (the day before the target when the wait since
                          `target_set_at` is 2+ local days; also 3 days before
                          at a wait of 8–15, 4 days before at 16+), ordered by
                          target. A task not considered since its segment (or
                          heads-up day) came round shows daily until it is.
                          TOP-LEVEL, never inside `groups`; never counted in
                          `total`, the nav badge or the slot notifications.
                        items:
                          type: object
                          properties:
                            horizon:
                              type: string
                              enum: [days, upcoming, weeks, months, eventually]
                            tasks:
                              type: array
                              items:
                                allOf:
                                  - $ref: '#/components/schemas/Task'
                                  - type: object
                                    properties:
                                      segment_state:
                                        type: string
                                        enum: [waiting, considered, deferred]
                                      recurring:
                                        type: boolean
                                        description: >
                                          A repeating task's occurrence (2026-10-12; Upcoming only).
                                          Clients offer no Remove target on it (its target goes with
                                          its recurrence); Today and its picker move only its target
                                          (spec §6).
                            waiting:
                              type: integer
                            considered:
                              type: integer
                            deferred:
                              type: integer
                            nudge:
                              type: boolean
                              description: Over 50 items — suggest Process or AI help.
                      has_any:
                        type: boolean
                        description: |
                          Whether the user has any reminders at all, today or
                          otherwise (reminders only — quota prompts do not set
                          it). Lets a client tell "finished for today" apart from
                          "has never made a reminder" when `groups` is empty.
                      groups:
                        type: array
                        items:
                          type: object
                          properties:
                            slot:
                              type: [object, 'null']
                              properties:
                                id:
                                  type: integer
                                label:
                                  type: string
                                start_time:
                                  type: string
                                  example: '07:00'
                                sort_order:
                                  type: integer
                            count:
                              type: integer
                            reminders:
                              type: array
                              items:
                                $ref: '#/components/schemas/Task'
                            considered:
                              type: integer
                              description: Considered today in this slot.
                            considered_items:
                              type: array
                              description: |
                                The considered ones, most recent first. Put one
                                back with `POST /api/tasks/{id}/undone`.
                              items:
                                $ref: '#/components/schemas/Task'
                            prompts:
                              type: array
                              description: |
                                Quota prompts assigned to this slot today,
                                alphabetical, handled ones included. Never
                                listed in considered_items (a quota refuses
                                /undone): put a handled one back with
                                `POST /api/quota-prompts/restore`.
                              items:
                                $ref: '#/components/schemas/QuotaPrompt'
                            prompts_waiting:
                              type: integer
                            prompts_considered:
                              type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Projects ---

  /api/projects:
    get:
      summary: List projects
      operationId: listProjects
      tags: [Projects]
      description: The user's own projects plus projects shared with them.
      responses:
        '200':
          description: List of projects with task counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      projects:
                        type: array
                        items:
                          $ref: '#/components/schemas/Project'
                      count:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create project
      operationId: createProject
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 200
                shared:
                  type: boolean
                  default: false
                sort_order:
                  type: integer
                  description: Position in the user's project order. Omitted = after the last project.
                color:
                  type: [string, 'null']
                  enum: [red, orange, yellow, green, blue, purple, pink, gray, null]
                  description: Omitted or null = a random colour not yet used by another of the user's projects.
                description:
                  type: [string, 'null']
                  maxLength: 280
                  description: |
                    What belongs in this project, so the AI and API clients
                    file tasks correctly. Trimmed; at most 280 characters after
                    trimming (400 over that). `null` or an empty string clears it.
      responses:
        '201':
          description: Created project
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/projects/reorder:
    patch:
      summary: Reorder projects
      operationId: reorderProjects
      tags: [Projects]
      description: |
        Sets each project's `sort_order` to its position in `project_ids`
        (0, 1, 2, ...). Only projects the user owns are reordered; ids of
        shared projects owned by someone else, or unknown ids, are ignored and
        the remaining owned projects keep their relative order.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_ids]
              properties:
                project_ids:
                  type: array
                  minItems: 1
                  items:
                    type: integer
                  description: Project ids in the desired order.
      responses:
        '200':
          description: Number of projects reordered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reordered:
                        type: integer
        '400':
          description: |
            `project_ids` is missing, empty or contains a non-integer, or none
            of the ids is a project the user owns
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/projects/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      summary: Get project
      operationId: getProject
      tags: [Projects]
      responses:
        '200':
          description: Project details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      summary: Update project
      operationId: updateProject
      tags: [Projects]
      description: |
        Owner only. The Inbox cannot be renamed (400); its other fields can change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 200
                shared:
                  type: boolean
                sort_order:
                  type: integer
                color:
                  type: [string, 'null']
                  enum: [red, orange, yellow, green, blue, purple, pink, gray, null]
                description:
                  type: [string, 'null']
                  maxLength: 280
                  description: |
                    What belongs in this project, so the AI and API clients
                    file tasks correctly. Trimmed; at most 280 characters after
                    trimming (400 over that). `null` or an empty string clears it.
      responses:
        '200':
          description: Updated project
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Project'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Only the project's owner can edit it
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      summary: Delete project
      operationId: deleteProject
      tags: [Projects]
      description: |
        Permanently deletes the project and moves each of its tasks to its
        owner's Inbox (in a shared project, every member's tasks go to that
        member's own Inbox). Not undoable. The Inbox itself cannot be deleted (400).
      responses:
        '200':
          description: Deletion result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message:
                        type: string
                        example: Project deleted
                      tasks_moved_to_inbox:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Only the project's owner can delete it
        '404':
          $ref: '#/components/responses/NotFound'

  # --- Undo / Redo ---

  /api/undo:
    post:
      summary: Undo last action
      operationId: undo
      tags: [Undo]
      description: |
        Reverses the most recent undoable action. The body is optional; send
        `session_start_id` to have the returned counts cover only entries after
        that id (see `GET /api/undo/status`) instead of all time.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UndoSessionScope'
      responses:
        '200':
          description: Undo result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UndoResult'
        '400':
          description: Nothing to undo
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/redo:
    post:
      summary: Redo last undone action
      operationId: redo
      tags: [Undo]
      description: |
        Re-applies the most recently undone action. The body is optional; send
        `session_start_id` to scope the returned counts, as for `POST /api/undo`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UndoSessionScope'
      responses:
        '200':
          description: Redo result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/RedoResult'
        '400':
          description: Nothing to redo
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/undo/status:
    get:
      summary: Undo stack status
      operationId: getUndoStatus
      tags: [Undo]
      description: |
        The newest undo-log id and how many entries can be undone and redone.
        A client can store `latest_id` when it starts and pass it back as
        `session_start_id` to scope undo to its own session.
      responses:
        '200':
          description: Undo stack status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      latest_id:
                        type: [integer, 'null']
                        description: Newest undo-log entry id, or null when the user has none.
                      undoable_count:
                        type: integer
                      redoable_count:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/undo/history:
    get:
      summary: Undo history
      operationId: getUndoHistory
      tags: [Undo]
      description: The user's most recent undo-log entries, newest first, both undone and not.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            default: 50
      responses:
        '200':
          description: Undo history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      history:
                        type: array
                        items:
                          $ref: '#/components/schemas/UndoHistoryEntry'
                      count:
                        type: integer
        '400':
          description: Invalid `limit`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/undo/batch:
    post:
      summary: Undo several actions
      operationId: undoBatch
      tags: [Undo]
      description: |
        Undoes entries from the top of the stack, newest first, in one
        transaction: all of them are undone or none are. Provide at least one
        of the fields; when several are given, every limit applies.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                session_start_id:
                  type: integer
                  minimum: 1
                  description: Undo every entry newer than this id.
                through_id:
                  type: integer
                  minimum: 1
                  description: Undo entries down to and including this id.
                count:
                  type: integer
                  minimum: 1
                  description: Undo at most this many entries.
      responses:
        '200':
          description: Batch undo result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UndoBatchResult'
        '400':
          description: No selector given, or nothing to undo
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/redo/batch:
    post:
      summary: Redo several actions
      operationId: redoBatch
      tags: [Undo]
      description: |
        Redoes undone entries, oldest undone first, in one transaction.
        Provide at least one of the fields.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                through_id:
                  type: integer
                  minimum: 1
                  description: Redo entries up to and including this id.
                count:
                  type: integer
                  minimum: 1
                  description: Redo at most this many entries.
      responses:
        '200':
          description: Batch redo result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UndoBatchResult'
        '400':
          description: No selector given, or nothing to redo
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Trash ---

  /api/trash:
    get:
      summary: List trashed tasks
      operationId: listTrash
      tags: [Trash]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 200
      responses:
        '200':
          description: Trashed tasks
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tasks:
                        type: array
                        items:
                          $ref: '#/components/schemas/Task'
                      count:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

    delete:
      summary: Empty trash (permanent delete)
      operationId: emptyTrash
      tags: [Trash]
      description: Permanently deletes all trashed tasks. Cannot be undone.
      responses:
        '200':
          description: Number of tasks permanently deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted_count:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Export ---

  /api/export:
    get:
      summary: Export user data
      operationId: exportData
      tags: [Export]
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [json, csv]
            default: json
        - name: type
          in: query
          schema:
            type: string
            enum: [tasks, projects]
          description: Required for CSV format. Each table is a separate file.
      responses:
        '200':
          description: Exported data
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tasks:
                        type: array
                        items:
                          $ref: '#/components/schemas/Task'
                      projects:
                        type: array
                        items:
                          $ref: '#/components/schemas/Project'
                      completions:
                        type: array
                        items:
                          $ref: '#/components/schemas/Completion'
                      exported_at:
                        type: string
                        format: date-time
            text/csv:
              schema:
                type: string
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Webhooks ---

  /api/webhooks:
    get:
      summary: List webhooks
      operationId: listWebhooks
      tags: [Webhooks]
      responses:
        '200':
          description: User's webhooks (secrets excluded)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      webhooks:
                        type: array
                        items:
                          $ref: '#/components/schemas/WebhookPublic'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create webhook
      operationId: createWebhook
      tags: [Webhooks]
      description: |
        Creates a webhook with an auto-generated HMAC secret.
        The secret is returned in this response only — store it securely.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreate'
      responses:
        '201':
          description: Created webhook with secret (shown once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Webhook'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    patch:
      summary: Update webhook
      operationId: updateWebhook
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookUpdate'
      responses:
        '200':
          description: Updated webhook (secret excluded)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookPublic'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      summary: Delete webhook
      operationId: deleteWebhook
      tags: [Webhooks]
      responses:
        '200':
          description: Deletion confirmed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/webhooks/{id}/deliveries:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      summary: List recent webhook deliveries
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      description: Returns the last 50 delivery attempts for debugging.
      responses:
        '200':
          description: Delivery history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deliveries:
                        type: array
                        items:
                          $ref: '#/components/schemas/WebhookDelivery'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # --- Tokens ---

  /api/tokens:
    get:
      summary: List API tokens
      operationId: listTokens
      tags: [Tokens]
      responses:
        '200':
          description: API tokens (previews only, not full tokens), newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tokens:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            token_preview:
                              type: [string, 'null']
                              description: Last 8 characters of the token
                            created_at:
                              type: string
                              format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create API token
      operationId: createToken
      tags: [Tokens]
      description: |
        Creates a new API token. The full token is returned in this response only.
        Tokens are stored as SHA-256 hashes — the raw token cannot be retrieved later.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: Trimmed; a blank name is a 400.
      responses:
        '201':
          description: Created token (shown once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      token:
                        type: string
                        description: Full token value (shown once)
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Tokens cannot be created by the demo account

  /api/tokens/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    delete:
      summary: Revoke API token
      operationId: deleteToken
      tags: [Tokens]
      responses:
        '200':
          description: Token revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # --- Auth ---

  /api/auth/session-from-token:
    post:
      summary: Exchange a Bearer token for a web session cookie
      operationId: createSessionFromToken
      tags: [Auth]
      description: |
        Mints a NextAuth session cookie for the owner of the supplied Bearer token and
        returns it in a `Set-Cookie` header. Used by the iOS app to log its WKWebView back
        in when the 7-day session JWT expires, using the long-lived token in its keychain.

        Bearer authentication only — a missing, malformed, or invalid `Authorization`
        header returns 401. This endpoint never falls back to session cookie or proxy
        header auth.

        The cookie is `HttpOnly`, `SameSite=Lax`, `Path=/`, expires in 7 days, and is named
        `__Secure-authjs.session-token` over HTTPS (`authjs.session-token` otherwise).
      responses:
        '200':
          description: Session cookie issued
          headers:
            Set-Cookie:
              description: NextAuth session cookie
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/auth/me:
    get:
      summary: Current user
      operationId: getCurrentUser
      tags: [Auth]
      description: The authenticated user's identity and core settings. Useful to check which account a token belongs to.
      responses:
        '200':
          description: The authenticated user
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      email:
                        type: string
                      name:
                        type: string
                        description: Username (the login name)
                      timezone:
                        type: string
                        example: America/New_York
                        description: IANA time zone
                      default_grouping:
                        type: string
                        enum: [project, new, unified]
                        description: |
                          Dashboard view: `project` = Now (grouped by project,
                          showing what is relevant now), `new` = Newest (flat,
                          newest added first); `unified` is the AI-sorted / "Unified" flat
                          list. The retired `slot` (Today) and `time` are
                          returned as `project`.
                      is_demo:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Stats ---

  /api/stats:
    get:
      summary: Get user statistics
      operationId: getStats
      tags: [Stats]
      parameters:
        - name: start
          in: query
          schema:
            type: string
            format: date
          description: Start date for daily stats range (YYYY-MM-DD)
        - name: end
          in: query
          schema:
            type: string
            format: date
          description: End date for daily stats range (YYYY-MM-DD)
      description: |
        Without `start` and `end`, returns a summary (today, this week, this
        month, all time). With both, returns `{ stats: [...] }`, one row per
        day that has stats in the range, oldest first.
      responses:
        '200':
          description: Stats summary or daily stats for date range
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: '#/components/schemas/StatsSummary'
                      - type: object
                        properties:
                          stats:
                            type: array
                            items:
                              $ref: '#/components/schemas/DailyStat'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Completions ---

  /api/user/preferences:
    get:
      summary: Get the user's preferences
      operationId: getUserPreferences
      tags: [User]
      description: |
        The signed-in user's settings. Only `week_start` is described field by
        field here; the response carries the rest of the Settings page's
        values (view, snooze, schedule, notification and AI settings) as well.
      responses:
        '200':
          description: The user's preferences
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UserPreferences'
        '401':
          $ref: '#/components/responses/Unauthorized'
    patch:
      summary: Update the user's preferences
      operationId: updateUserPreferences
      tags: [User]
      description: |
        Send only the fields to change. Changing `week_start` closes, before
        the response is returned, any weekly quota period the new boundary has
        already ended (see `UserPreferences.week_start`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserPreferences'
      responses:
        '200':
          description: The user's preferences after the update
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/UserPreferences'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/completions:
    get:
      summary: Query completion history
      operationId: listCompletions
      tags: [Stats]
      parameters:
        - name: task_id
          in: query
          schema:
            type: integer
        - name: date
          in: query
          schema:
            type: string
            format: date
          description: Filter by completion date (YYYY-MM-DD, UTC calendar date)
        - name: since
          in: query
          schema:
            type: string
            format: date-time
          description: Filter to completions at or after this instant. Must be paired with `until`.
        - name: until
          in: query
          schema:
            type: string
            format: date-time
          description: Filter to completions before this instant. Must be paired with `since`.
      responses:
        '200':
          description: Completion records
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      completions:
                        type: array
                        items:
                          $ref: '#/components/schemas/Completion'
                      count:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Review ---

  /api/review:
    get:
      summary: Start a review session
      operationId: createReviewSession
      tags: [Review]
      description: |
        Numbers the user's overdue tasks and tasks due within the next 24
        hours (overdue first, then by due date) so a client can act on them by
        number with `POST /api/review/execute`. Sessions are held in server
        memory: one is discarded once executed, sessions older than an hour
        are purged when a new one starts, and all are lost on a server restart.
      responses:
        '200':
          description: The review session
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session_id:
                        type: string
                        format: uuid
                      groups:
                        type: array
                        description: Non-empty groups only, `Overdue` before `Today`.
                        items:
                          type: object
                          properties:
                            label:
                              type: string
                              enum: [Overdue, Today]
                            tasks:
                              type: array
                              items:
                                type: object
                                properties:
                                  seq:
                                    type: integer
                                    description: The task's number in this session, from 1.
                                  id:
                                    type: integer
                                  title:
                                    type: string
                                  due_at:
                                    type: string
                                    format: date-time
                                  priority:
                                    type: integer
                                  rrule:
                                    type: [string, 'null']
                                  labels:
                                    type: array
                                    items:
                                      type: string
                      total_tasks:
                        type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/review/execute:
    post:
      summary: Act on a review session
      operationId: executeReview
      tags: [Review]
      description: |
        Applies actions to tasks by their `seq` numbers from `GET /api/review`.
        Actions run in order, each as its own bulk operation (and undo entry);
        they are not one transaction. The whole body is validated first, so a
        `snooze` without `until` is a 400 before any action runs. Review
        targets are explicit picks: `snooze` moves High and Urgent targets too,
        where a sweep would hold them back. The session is discarded afterwards.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session_id, actions]
              properties:
                session_id:
                  type: string
                actions:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [type, targets]
                    properties:
                      type:
                        type: string
                        enum: [done, snooze, skip]
                        description: '`skip` changes nothing; it only counts the targets.'
                      targets:
                        type: array
                        minItems: 1
                        items:
                          type: string
                        description: Seq numbers or inclusive ranges.
                        example: ['1', '3-5']
                      until:
                        type: string
                        format: date-time
                        description: Required for `snooze` (400 without it).
      responses:
        '200':
          description: What each action did
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      executed:
                        type: boolean
                      results:
                        type: array
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              enum: [done, snooze, skip]
                            taskIds:
                              type: array
                              description: Task ids the targets resolved to (note the camelCase name).
                              items:
                                type: integer
                            count:
                              type: integer
                              description: Tasks the action changed.
                      total_affected:
                        type: integer
        '400':
          description: |
            Invalid body, a `snooze` action without `until`, or a malformed
            target
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: A target number is not in the session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Review session not found or expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # --- AI ---
  # Every AI endpoint except GET /api/ai/insights/results and
  # GET /api/ai/insights/status returns 503 SERVICE_UNAVAILABLE when the
  # server runs without AI enabled.

  /api/ai/status:
    get:
      summary: AI status and activity
      operationId: getAiStatus
      tags: [AI]
      description: |
        Diagnostics for the AI features: the state of the warm model slots and
        request queue (server-wide), work in progress for this user, and the
        user's recent AI activity log. The slot objects use camelCase keys.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
          description: Activity entries to return.
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: action
          in: query
          schema:
            type: string
            example: enrichment
          description: Only activity entries of this action.
      responses:
        '200':
          description: AI status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enrichment_slot:
                        allOf:
                          - $ref: '#/components/schemas/AISlotStats'
                          - type: object
                            properties:
                              currentOperation:
                                type: [object, 'null']
                                properties:
                                  taskId:
                                    type: [integer, 'null']
                                  inputText:
                                    type: [string, 'null']
                                  startedAt:
                                    type: [string, 'null']
                      quick_take_slot:
                        allOf:
                          - $ref: '#/components/schemas/AISlotStats'
                          - type: object
                            properties:
                              totalSuperseded:
                                type: integer
                              currentOperation:
                                type: [object, 'null']
                                properties:
                                  inputText:
                                    type: [string, 'null']
                                  startedAt:
                                    type: [string, 'null']
                      queue:
                        type: object
                        properties:
                          active:
                            type: integer
                          waiting:
                            type: integer
                          maxConcurrent:
                            type: integer
                      in_progress:
                        type: object
                        properties:
                          enrichment:
                            type: object
                            properties:
                              slot:
                                type: [object, 'null']
                                description: Same as `enrichment_slot.currentOperation`.
                              pipeline_task_ids:
                                type: array
                                items:
                                  type: integer
                              circuit_breaker_open:
                                type: boolean
                              pending_count:
                                type: integer
                                description: The user's tasks still waiting for enrichment.
                          insights:
                            type: [object, 'null']
                            properties:
                              session_id:
                                type: string
                              status:
                                type: string
                                enum: [running, complete, failed]
                              total_tasks:
                                type: integer
                              completed:
                                type: integer
                              started_at:
                                type: string
                                format: date-time
                      recent_activity:
                        type: array
                        items:
                          $ref: '#/components/schemas/AIActivityEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/AIUnavailable'

  /api/ai/test:
    post:
      summary: Test an AI feature's model
      operationId: testAiFeature
      tags: [AI]
      description: |
        Sends a minimal prompt through the provider and model configured for
        one feature, to check that it responds. A provider failure is reported
        in the 200 body (`success: false`, `error`), not as an HTTP error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [feature]
              properties:
                feature:
                  type: string
                  enum: [enrichment, quick_take, whats_next, insights]
      responses:
        '200':
          description: Test result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success:
                        type: boolean
                      duration_ms:
                        type: integer
                      provider:
                        type: string
                      provider_display:
                        type: string
                      model:
                        type: string
                      model_display:
                        type: string
                      error:
                        type: [string, 'null']
        '400':
          description: |
            Unknown feature, the feature is turned off for this user, or no
            provider is available for it
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/AIUnavailable'

  /api/ai/quick-take:
    post:
      summary: Quick take on a new task
      operationId: getQuickTake
      tags: [AI]
      description: A one-line AI comment on a task title, in the context of the user's task list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  minLength: 1
                has_due_date:
                  type: boolean
                  default: false
      responses:
        '200':
          description: The quick take
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      text:
                        type: [string, 'null']
                        description: Null when the feature is off for this user, or the call failed or timed out.
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/AIUnavailable'

  /api/ai/whats-next:
    get:
      summary: What's Next recommendations
      operationId: getWhatsNext
      tags: [AI]
      description: |
        Returns today's cached recommendations. Without `refresh=true` this
        never generates: when nothing was generated today (UTC) `data` is
        null. With `refresh=true` it runs the model and waits for the result.
      parameters:
        - name: refresh
          in: query
          schema:
            type: boolean
          description: Generate fresh recommendations now.
      responses:
        '200':
          description: Recommendations, or null
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: [object, 'null']
                    properties:
                      tasks:
                        type: array
                        items:
                          type: object
                          properties:
                            task_id:
                              type: integer
                            reason:
                              type: string
                      summary:
                        type: string
                      generated_at:
                        type: string
                      duration_ms:
                        type: [integer, 'null']
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          description: AI is not enabled, What's Next is turned off, or generation failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/ai/insights/generate:
    post:
      summary: Start Insights generation
      operationId: generateInsights
      tags: [AI]
      description: |
        Scores all of the user's active tasks in the background and returns at
        once. Poll `GET /api/ai/insights/status` with the returned
        `session_id`, then read `GET /api/ai/insights/results`.
      responses:
        '200':
          description: |
            Generation started. With no active tasks, `session_id` is null,
            `total_tasks` is 0 and `message` says so.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session_id:
                        type: [string, 'null']
                      total_tasks:
                        type: integer
                      single_call:
                        type: boolean
                        description: All tasks fit in one model call.
                      started_at:
                        type: string
                        format: date-time
                      message:
                        type: string
                        example: No active tasks to analyze
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: |
            A generation is already running; `details` carries its
            `session_id`, `completed`, `total_tasks` and `started_at`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: AI is not enabled, or Insights is turned off
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/ai/insights/status:
    get:
      summary: Insights generation progress
      operationId: getInsightsStatus
      tags: [AI]
      description: Works whether or not AI is enabled (it only reads the stored session).
      parameters:
        - name: session_id
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Session progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session_id:
                        type: string
                      status:
                        type: string
                        enum: [running, complete, failed]
                      total_tasks:
                        type: integer
                      completed:
                        type: integer
                      progress_pct:
                        type: integer
                        minimum: 0
                        maximum: 100
                      started_at:
                        type: string
                        format: date-time
                      finished_at:
                        type: [string, 'null']
                        format: date-time
                      error:
                        type: [string, 'null']
        '400':
          description: Missing `session_id`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/ai/insights/results:
    get:
      summary: Insights results
      operationId: getInsightsResults
      tags: [AI]
      description: |
        The latest Insights scores for the user's tasks, highest score first,
        with the signal vocabulary for display. Works whether or not AI is
        enabled (it only reads stored results).
      responses:
        '200':
          description: Insights results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      results:
                        type: array
                        items:
                          type: object
                          properties:
                            task_id:
                              type: integer
                            score:
                              type: integer
                              minimum: 0
                              maximum: 100
                              description: Attention score; higher = more worth a look.
                            commentary:
                              type: string
                            signals:
                              type: array
                              maxItems: 2
                              items:
                                $ref: '#/components/schemas/InsightsSignalKey'
                            generated_at:
                              type: string
                              format: date-time
                      generated_at:
                        type: [string, 'null']
                        format: date-time
                      stale:
                        type: boolean
                        description: There are results, but not from today (UTC).
                      duration_ms:
                        type: [integer, 'null']
                        description: How long the last completed generation took.
                      signal_counts:
                        type: object
                        description: Number of results carrying each signal.
                        additionalProperties:
                          type: integer
                      signals:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              $ref: '#/components/schemas/InsightsSignalKey'
                            label:
                              type: string
                            color:
                              type: string
                            icon:
                              type: string
                            description:
                              type: string
                      active_session:
                        type: [object, 'null']
                        properties:
                          session_id:
                            type: string
                          completed:
                            type: integer
                          total_tasks:
                            type: integer
                          started_at:
                            type: string
                            format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Process (AI half of the Process button) ---

  /api/process/recommend-projects:
    post:
      summary: Recommend a project for each task
      operationId: recommendProjects
      tags: [AI]
      description: |
        For the Process button's sorting step: the project each given task
        probably belongs in, or `null` when none clearly fits. The AI only
        recommends — nothing is written; the user picks. It reads each
        project's `description` (what belongs there) and the user's global
        `ai_context`.

        Ids that are not the caller's own live tasks are ignored. A
        `project_id` is always one of the caller's own or shared projects,
        never the Inbox; a model answer naming any other project is dropped,
        so a task can be missing from `recommendations` (no recommendation).
        Entries follow the order of `ids`.

        AI disabled (server-wide, or the user's Insights mode set to off)
        returns `{ recommendations: [] }` with 200, never an error. Uses the
        Insights feature's AI mode, model and timeout.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: integer
                    minimum: 1
      responses:
        '200':
          description: Recommendations (empty when AI is off)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      recommendations:
                        type: array
                        items:
                          type: object
                          properties:
                            task_id:
                              type: integer
                            project_id:
                              type: [integer, 'null']
                              description: The recommended project, or null when none fits.
                            reason:
                              type: string
                              description: One short sentence to show next to the pick.
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          description: The model call failed or returned nothing usable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/process/duplicates:
    get:
      summary: Possible duplicate tasks
      operationId: findDuplicates
      tags: [AI]
      description: |
        For the Process button's duplicate step: groups of the caller's open
        one-off tasks (not recurring tasks, reminders or quotas) that look like
        the same piece of work. Each group has 2+ task ids and a task is in at
        most one group. Nothing is written. Scans the newest 600 such tasks.

        AI disabled (server-wide, or the user's Insights mode set to off)
        returns `{ groups: [] }` with 200, never an error.
      responses:
        '200':
          description: Duplicate groups (empty when AI is off or none were found)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      groups:
                        type: array
                        items:
                          type: object
                          properties:
                            task_ids:
                              type: array
                              minItems: 2
                              items:
                                type: integer
                            reason:
                              type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          description: The model call failed or returned nothing usable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # --- Notifications ---

  /api/notifications/actions:
    post:
      summary: Act on a task from a notification
      operationId: notificationAction
      tags: [Notifications]
      security: []
      description: |
        Done, snooze or delete for a single task, as sent by notification
        action buttons. The API token goes in the body, not the `Authorization`
        header, because the iOS notification extension cannot set headers.

        - `done` — mark the task done
        - `snooze30` — snooze 30 minutes from now
        - `snooze` — snooze 1 hour from now, rounded to the hour
        - `snooze2h` — snooze 2 hours from now, rounded to the hour
        - `delete` — move the task to the trash (a soft delete, undoable like
          any other delete; the "AI finished" notification's Delete button).
          A task already in the trash is a 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action, task_id, token]
              properties:
                action:
                  type: string
                  enum: [done, snooze, snooze30, snooze2h, delete]
                task_id:
                  type: integer
                  minimum: 1
                token:
                  type: string
                  description: An API token (the same value you would send as a Bearer token).
      responses:
        '200':
          description: |
            The action taken. `result` is the task operation's own result: its
            `task` is the stored task, without the computed `is_recurring` and
            `target_at` fields.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      action:
                        type: string
                        enum: [done, snooze, snooze30, snooze2h, delete]
                      task_id:
                        type: integer
                      until:
                        type: string
                        format: date-time
                        description: Snooze actions only.
                      result:
                        type: object
                        properties:
                          task:
                            $ref: '#/components/schemas/Task'
                          wasRecurring:
                            type: boolean
                            description: '`done` only.'
                          nextDueAt:
                            type: [string, 'null']
                            format: date-time
                            description: '`done` on a recurring task only.'
                          previousDueAt:
                            type: [string, 'null']
                            format: date-time
                            description: Snooze actions only.
                          originalDueAt:
                            type: [string, 'null']
                            format: date-time
                            description: Snooze actions only.
                          description:
                            type: [string, 'null']
                            description: Snooze actions only.
        '400':
          description: Invalid `task_id`, unknown action, or the task cannot take it
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Missing or invalid `token`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: The token's user cannot access this task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/notifications/dismiss-all:
    post:
      summary: Dismiss all notifications
      operationId: dismissAllNotifications
      tags: [Notifications]
      description: Clears the user's delivered notifications on every channel and device.
      responses:
        '200':
          description: Dismissal sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      dismissed:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/notifications/test:
    post:
      summary: Send a test notification
      operationId: sendTestNotification
      tags: [Notifications]
      description: |
        Creates a real task (labelled `test`, due a minute ago) and, about 3
        seconds later, sends a notification for it to every configured
        channel (Web Push and APNs), so Done and Snooze can be tried end to
        end. `high` sends a High (P3) notification; `urgent` and `critical`
        both send an Urgent (P4) one as an Apple Critical Alert, the same as a
        real overdue P4.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [individual, high, bulk, urgent, critical]
      responses:
        '200':
          description: Test scheduled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      sent:
                        type: boolean
                      type:
                        type: string
                      task_id:
                        type: integer
                        description: The test task that was created.
                      delay_seconds:
                        type: integer
                        example: 3
        '400':
          description: Invalid `type`, or no notification channel is configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # --- Push ---

  /api/push/vapid-key:
    get:
      summary: Web Push public key
      operationId: getVapidKey
      tags: [Push]
      security: []
      responses:
        '200':
          description: The server's VAPID public key
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      publicKey:
                        type: string
                        description: Empty string when not configured.
                      configured:
                        type: boolean

  /api/push/test:
    post:
      summary: Send a test Web Push notification
      operationId: sendTestPush
      tags: [Push]
      description: Sends a test notification to the user's Web Push subscriptions only.
      responses:
        '200':
          description: Whether it was sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      sent:
                        type: boolean
                      reason:
                        type: string
                        example: VAPID keys not configured
                        description: Present when `sent` is false.
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/push/apns/register:
    post:
      summary: Register an APNs device
      operationId: registerApnsDevice
      tags: [Push]
      description: |
        Registers an Apple device token for push notifications. Re-registering
        an existing token moves it to the calling user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_token, bundle_id]
              properties:
                device_token:
                  type: string
                bundle_id:
                  type: string
                  example: com.example.app
                environment:
                  type: string
                  enum: [production, development]
                  default: production
                  description: Any value other than `development` is stored as `production`.
      responses:
        '200':
          description: Registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      registered:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
    delete:
      summary: Unregister an APNs device
      operationId: unregisterApnsDevice
      tags: [Push]
      description: Removes the device token, whichever user registered it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_token]
              properties:
                device_token:
                  type: string
      responses:
        '200':
          description: Unregistered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      unregistered:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/push/apns/widget-token:
    post:
      summary: Register a widget push token
      operationId: registerWidgetPushToken
      tags: [Push]
      description: |
        Registers a WidgetKit push token (a widget extension's token, separate
        from the app's device token) so the server can tell widgets to reload
        when the user's data changes. Re-registering a token moves it to the
        calling user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [push_token, bundle_id, platform]
              properties:
                push_token:
                  type: string
                bundle_id:
                  type: string
                  example: com.example.app
                platform:
                  type: string
                  enum: [ios, macos, watchos]
                environment:
                  type: string
                  enum: [production, development]
                  default: production
                  description: Any value other than `development` is stored as `production`.
                widget_kind:
                  type: [string, 'null']
      responses:
        '200':
          description: Registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      registered:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
    delete:
      summary: Unregister a widget push token
      operationId: unregisterWidgetPushToken
      tags: [Push]
      description: Removes the push token, whichever user registered it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [push_token]
              properties:
                push_token:
                  type: string
      responses:
        '200':
          description: Unregistered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      unregistered:
                        type: boolean
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API token created via POST /api/tokens or the db:create-token CLI command

  parameters:
    TaskId:
      name: id
      in: path
      required: true
      description: Digits only. A malformed id (e.g. `abc` or `12abc`) is a 400 on every `{id}` route.
      schema:
        type: integer

  responses:
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    ValidationError:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    AIUnavailable:
      description: AI features are not enabled on this server
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    PromptActionResult:
      description: What the prompt action did, and the quotas it touched
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  considered:
                    type: integer
                  did:
                    type: integer
                  tasks:
                    type: array
                    items:
                      $ref: '#/components/schemas/Task'

  schemas:
    UserPreferences:
      type: object
      additionalProperties: true
      properties:
        default_grouping:
          type: string
          enum: [project, new, unified]
          description: |
            The dashboard view the switch last chose: `project` = Now (every
            open task, grouped by project; each project shows what is relevant
            now — over target, targeted today, in today's Upcoming heads-up,
            or undated High/Urgent — and folds the rest behind "+ N more";
            "Show all" lifts the folds, each group then showing
            `project_preview_count` rows before "Show all"), `new` = Newest
            (every open task in one flat list, newest added first, each row
            naming its project; ignores `default_sort`). `unified` is the flat
            list the AI-sort toggle and the list's "Unified" button switch to.

            `slot` (the Today view, today's tasks by time of day, dropped
            2026-10-14) and `time` (All grouped by due date, 2026-09-29 to
            2026-09-30) are retired: still accepted on PATCH so older clients
            don't get a 400, but stored and returned as `project`. Any other
            value is a 400.
        project_preview_count:
          type: integer
          minimum: 1
          maximum: 50
          default: 6
          description: |
            How many tasks each project group shows, with Now's folds lifted
            ("Show all", the old All view), before its "Show all" button.
            Now's own rows are not capped.
        auto_snooze_minutes:
          type: integer
          minimum: 1
          maximum: 360
          default: 30
          description: |
            Minutes between repeat notifications for an overdue unset-priority
            (P0) task. A task's own `auto_snooze_minutes` overrides every tier.
        auto_snooze_low_minutes:
          type: integer
          minimum: 1
          maximum: 1440
          default: 240
          description: Minutes between repeat notifications for an overdue Low (P1) task.
        auto_snooze_medium_minutes:
          type: integer
          minimum: 1
          maximum: 1440
          default: 60
          description: Minutes between repeat notifications for an overdue Medium (P2) task.
        auto_snooze_high_minutes:
          type: integer
          minimum: 1
          maximum: 360
          default: 15
          description: Minutes between repeat notifications for an overdue High (P3) task.
        auto_snooze_urgent_minutes:
          type: integer
          minimum: 1
          maximum: 360
          default: 5
          description: Minutes between repeat notifications for an overdue Urgent (P4) task.
        default_reminder_slot_id:
          type: [integer, 'null']
          description: |
            The user's default reminder slot (a time slot id; Settings ->
            Reminder periods -> Default period). A reminder with no stated time
            goes here: one created without a schedule, and one whose text AI
            enrichment finds no time cue in. Quotas that have not chosen a
            period prompt here too. `null` means the first period of the day
            (also the fallback when the stored slot no longer exists). Must be
            one of the user's own slots.
        quota_prompt_slot_id:
          type: [integer, 'null']
          description: |
            The original name of `default_reminder_slot_id` - the same
            setting, returned and accepted under both names. Sending both with
            different values is a 400.
        quota_prompts_enabled:
          type: boolean
          description: Whether unmet quotas also appear as prompts among the user's reminders.
        enrichment_notifications_enabled:
          type: boolean
          default: true
          description: |
            Whether a quiet push notification (no sound, Notification Center
            only) is sent when AI enrichment finishes for a task or reminder
            added in the last 10 minutes: its cleaned title, and the project,
            due date, priority, recurrence and labels it now has (a reminder's
            period instead of a project). Sent once per task, never for a
            failed enrichment, and only while notifications are enabled.
        sweep_feedback_notifications_enabled:
          type: boolean
          default: true
          description: |
            Whether a quiet push notification (a banner without sound) is sent
            when `POST /api/tasks/bulk/snooze-overdue`, called with an API
            token (a notification button, a widget, a Shortcut), leaves High
            or Urgent tasks overdue. It gives counts only, never task titles:
            e.g. "Snoozed 8 tasks until 3:00 PM" / "2 High and 1 Urgent still
            overdue. Snooze again to move the High ones". Only while
            notifications are enabled. A single call can suppress it with
            `notify: false`.
        week_start:
          type: string
          enum: [sunday, monday]
          default: sunday
          description: |
            The first day of the user's week - where every weekly quota's
            period begins and ends (00:00 local on that day). `sunday` makes
            Saturday the last day of a week's quotas.

            A weekly quota whose current period did not begin on the new
            boundary (it was anchored under the other setting) ends at the NEXT
            boundary after it began - a short period, recorded like any other
            (met: a completion; short: recorded only) - and is aligned from
            then on. Progress logged after that boundary stays in the new
            period. With INTERVAL > 1 the short period is at most one week and
            the next period runs the full interval from the boundary.
        check_in_day:
          type: integer
          minimum: 1
          maximum: 7
          default: 6
          description: |
            The weekday the horizon task segments come round on (2026-10-12),
            in Luxon numbering: 1 = Monday … 7 = Sunday; default 6 = Saturday.
            Weeks shows on it, Months on the first one of the month, Eventually
            on the first one of January, April, July and October. Independent
            of `week_start`; no part of Upcoming. PATCH refuses anything but an
            integer 1–7 (400).
        default_snooze_option:
          type: string
          default: '60'
          description: |
            Where a single-tap snooze sends an overdue task (the row's snooze
            button, a swipe), and what `POST /api/tasks/bulk/snooze-overdue`
            uses with an empty body. A string count of minutes (`"1"` to
            `"1440"`; an hour or more is rounded to the nearest hour),
            `"tomorrow"` (tomorrow at `morning_time`), or `"next_period"`: the
            start of the next reminder period - later today, or tomorrow's
            first period once the day's last one has begun. With no periods,
            `"next_period"` acts as `"60"`.
        ai_context:
          type: [string, 'null']
          maxLength: 4000
          description: |
            Free-text context about the user that every AI feature sees
            (Settings -> AI Context). Trimmed; an empty string clears it
            (`null`). Not writable by the demo account (403).
        ai_context_updated_at:
          type: [string, 'null']
          format: date-time
          readOnly: true
          description: |
            When `ai_context` last changed (UTC), stamped by the server on a
            PATCH that changes the text - re-sending the same text leaves it
            alone, clearing it counts as a change. `null` when it has never
            changed since this was first recorded (2026-10-10). The app shows a
            quiet "review your AI context" nudge once it is 21 days old, or
            when it is `null`. Not writable: a PATCH carrying only this field
            is a 400. To mark the context reviewed without changing it, PATCH
            `ai_context_reviewed: true` (write-only; anything but `true` is a
            400; 403 for the demo account), which restamps it to now.
    PromptKeys:
      type: object
      required: [keys]
      properties:
        keys:
          type: array
          minItems: 1
          maxItems: 500
          items:
            type: string
            example: 'q:42:0:2026-09-24'
        source:
          $ref: '#/components/schemas/ProgressSource'

    ProgressSource:
      type: string
      enum: [web, widget, watch, mac, api]
      default: api
      description: >
        Where progress was logged from, recorded on its progress event
        (`GET /api/tasks/{id}/progress-events`). Optional; `api` when absent.

    PromptAction:
      type: object
      required: [key]
      properties:
        key:
          type: string
          example: 'q:42:2:2026-09-24'
        did:
          type: boolean
          default: false
          description: true = did it (log progress + consider); false = consider only.

    QuotaPrompt:
      type: object
      description: |
        One quota prompt (quota reminders, 2026-09-24): an unmet quota shown in
        a reminder period. Not a task — key rows by `prompt_key`, act on it with
        the quota-prompt endpoints; `task_id` is the quota (for opening it).
      properties:
        prompt_key:
          type: string
          description: >
            `q:<taskId>:<k>:<date>` — k is the prompt's number for a daily quota
            (the highest number assigned to this slot), 0 otherwise; date is the
            user's local date.
          example: 'q:42:0:2026-09-24'
        task_id:
          type: integer
        number:
          type: [integer, 'null']
          description: >
            Daily quotas: the row is done once today's count reaches this.
            Null for every other quota (one prompt a day).
        numbers:
          type: [array, 'null']
          items:
            type: integer
          description: >
            Daily quotas: every prompt number this row stands for, ascending
            (`number` is the last). Moving the row to another period means
            setting `quota_prompt_config.numbers[k]` for exactly these. Null for
            every other quota — move those with `quota_prompt_config.slot_id`.
        slot_id:
          type: [integer, 'null']
          description: The period (time slot id) this row sits in; null in the un-slotted group.
        title:
          type: string
        current:
          type: integer
          description: Today's count (0 once the period ended, even before the rollover job runs).
        target:
          type: integer
        period:
          type: [string, 'null']
          enum: [DAILY, WEEKLY, MONTHLY, YEARLY, null]
          description: >
            The quota rule's FREQ - bare, even for an interval quota; read
            `period_interval` and `period_label` beside it.
        period_interval:
          type: [integer, 'null']
          description: The quota rule's INTERVAL (1 when it has none) - the task's `period_interval`.
        period_end:
          type: [string, 'null']
          format: date-time
          description: The current period's exclusive end (UTC) - the task's `progress_period_end`.
        period_last_day:
          type: [string, 'null']
          format: date
          description: The current period's last local day - the task's `period_last_day`.
        period_label:
          type: [string, 'null']
          description: >
            "by Oct 24" for an interval quota (`period_interval` above 1); null
            at interval 1, where the client keeps its own word for `period`
            ("· week"). The task's `period_label`.
          example: by Oct 24
        stripe_color:
          type: [string, 'null']
          enum: [red, orange, yellow, blue, purple, pink, gray, null]
          description: >
            The quota's label colour for its stripe, resolved server-side. Null
            is neutral (no label or colour); green is never sent — it means
            "met" on quota chips.
        has_notes:
          type: boolean
          description: >
            The quota has non-blank notes. A flag only, for the rows' notes
            mark; the notes themselves are on the quota task.
        considered:
          type: boolean
        done:
          type: boolean
          description: >
            Daily: today's count reached `number`. Others: progress was logged
            today from anywhere (or "did it" on this prompt).

    Error:
      type: object
      properties:
        error:
          type: string
        code:
          type: string
          enum:
            - VALIDATION_ERROR
            - NOT_FOUND
            - UNAUTHORIZED
            - FORBIDDEN
            - CONFLICT
            - SERVICE_UNAVAILABLE
            - INTERNAL_ERROR
        details:
          type: object

    Task:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
        project_id:
          type: integer
        title:
          type: string
        original_title:
          type: [string, 'null']
          description: Raw input text before AI enrichment
        short_title:
          type: [string, 'null']
          maxLength: 24
          description: >
            Optional short label for the quota widget chip (§5). Only
            meaningful on a quota (progress_target > 1 or is_tracked), but not
            rejected on an ordinary task.
            Kept for API compatibility; the web editor no longer sets it, and
            clients fall back to `title` when it is null.
        done:
          type: boolean
        done_at:
          type: [string, 'null']
          format: date-time
        priority:
          type: integer
          minimum: 0
          maximum: 4
          description: '0=unset, 1=low, 2=medium, 3=high, 4=urgent'
        due_at:
          type: [string, 'null']
          format: date-time
        rrule:
          type: [string, 'null']
          description: RFC 5545 recurrence rule
        recurrence_mode:
          type: string
          enum: [from_due, from_completion]
        anchor_time:
          type: [string, 'null']
        anchor_dow:
          type: [integer, 'null']
        anchor_dom:
          type: [integer, 'null']
        original_due_at:
          type: [string, 'null']
          format: date-time
          description: >
            The FIRST-TARGET date (2026-10-10): every move of the target keeps
            it; a task getting a target after having none restarts it; a
            repeating task's done or skip sets it to the next occurrence. The
            target-age badge counts whole local days from it (and shipped native
            builds read it as their age clock).
        target_at:
          type: [string, 'null']
          format: date-time
          description: >
            The TARGET — when nagging starts (2026-10-10). The primary name;
            `due_at` carries the same value under its deprecated name.
        deadline_at:
          type: [string, 'null']
          format: date-time
          description: >
            The real cutoff. Never moved by a snooze. While it is ahead the
            target is never after it: every move stops at it (2026-10-12, spec
            §8.2). Once it has passed, any target can be picked, and a bulk
            snooze treats the task as High. A one-off
            with a deadline always has a target — with none picked, the stored
            automatic one (`target_auto`). On a recurring task it moves with
            each occurrence, measured from `occurrence_at`.
        horizon:
          type: [string, 'null']
          enum: [days, weeks, months, eventually, null]
          description: >
            How soon it should get done; picks the task segment it appears in.
            null = unset (treated as weeks, never reported as set).
        horizon_set_at:
          type: [string, 'null']
          format: date-time
          description: When the horizon was last set — Process's growth clock.
        last_considered_at:
          type: [string, 'null']
          format: date-time
          description: The task-segment circle ("I saw it"). Never a completion.
        segment_deferred_at:
          type: [string, 'null']
          format: date-time
          description: A task segment's Defer All — hidden for that local day.
        segment_targeted_at:
          type: [string, 'null']
          format: date-time
          description: >
            When the task was last targeted FROM A TASK SEGMENT — `POST /api/task-segments/target-today`
            (the row's Today button, its time picker, and Process's "Do it"). The dashboard lists it in
            the Just now card for 15 minutes. Deliberately not a general "target changed" stamp: a
            snooze, an edit, a create or a recurrence advance never sets it. Undo restores the previous
            value.
        target_set_at:
          type: [string, 'null']
          format: date-time
          description: >
            When the task's target was last set, by any path (2026-10-12): create, any move, Today,
            a completion advance, skip, put-back, AI enrichment, or a change to `deadline_at` (it can
            move the target). The anchor of Upcoming's wait (docs/TASK-MODEL.md § Upcoming).
            Undo restores it. Null on rows written before it existed: read as `created_at` (a
            repeating task: the later of that and `last_completed_at`). Server-set; never sent.
        target_auto:
          type: boolean
          description: >
            `due_at` is the deadline's AUTOMATIC target (2026-10-12, spec §8): stored by the server
            for a one-off with a deadline and no target the user picked — the user's morning 3 days
            before the deadline's day (4 when the deadline was set 16+ days out; now, if that moment
            had passed). It follows the deadline when the deadline changes. Only a picked target
            (false) makes a task quiet until its date (Upcoming). Server-set.
        occurrence_at:
          type: [string, 'null']
          format: date-time
          description: >
            A repeating task's SCHEDULED occurrence (2026-10-12, spec §6); `due_at` is its target,
            which a move changes alone. Done and skip advance from this and set both; the deadline
            measures from it. Null for a one-off. Server-set.
        deadline_nag_off:
          type: boolean
          deprecated: true
          description: >
            No longer stored since 2026-10-12 (spec §8: a deadline's target is stored). Kept for
            shipped native builds, which decode it and run their own implied-target rule: true only
            for a one-off whose deadline has passed and whose target is after it, which makes that
            rule honor the target; false otherwise.
        attempts:
          type: integer
          description: >
            ATTEMPTS (spec §7, 2026-10-12): the local days (owner's timezone) on which the task was
            over target, as of this response — a day counts the moment the task goes over target,
            once per day; days before the target never count. Reset by done and by each new
            occurrence of a repeating task; frozen while there is no target. Never counts for a
            reminder or a quota. Server-computed: draw `attempts_label`.
        attempts_counted_through:
          type: [string, 'null']
          format: date
          description: >
            The local date `attempts` is counted through (the checkpoint the server advances on
            reads, on target moves and every minute). Server-set.
        attempts_label:
          type: [string, 'null']
          description: >
            The attempts badge, e.g. "3×" — shown only while the task has a target and at least
            one attempt; null otherwise.
        attempts_offer:
          type: [string, 'null']
          enum: [remove, auto, null]
          description: >
            At 3 attempts or more, what the row offers (never does on its own): `remove` ("Remove
            target?" — a one-off with no deadline), `auto` ("Back to auto?" — a deadline still ahead
            and a target the user picked; send `target_at: null`). Null below 3×, on an automatic
            target, on a repeating task, and once the deadline has passed.
        age_label:
          type: [string, 'null']
          description: >
            The age badge (spec §7): ">1 wk" (days), ">1 mo" (weeks or unset), ">3 mo" (months),
            ">6 mo" (eventually) — only on a one-off with neither a target nor a deadline, once
            `created_at` is past its horizon's threshold. Null otherwise.
        growth_kept_at:
          type: [string, 'null']
          format: date-time
          description: >
            The last Process "keep" of the horizon (`affirm_horizon`); Process asks to grow it
            again only one threshold period after it. Server-set.
        auto_snooze_minutes:
          type: [integer, 'null']
        deleted_at:
          type: [string, 'null']
          format: date-time
        archived_at:
          type: [string, 'null']
          format: date-time
        labels:
          type: array
          items:
            type: string
        notes:
          type: [string, 'null']
        progress_target:
          type: integer
          description: >
            Track / quota target (REDESIGN-V03 §5). 1 means the task is not
            tracked - every task is already a quota with target 1, so tracking
            is opt-in simply by setting this above 1.
        progress_current:
          type: integer
          description: >
            How many increments are logged this period. Reaching the target
            marks the task "met" but does NOT complete it; the task stays open
            until its period boundary, so overflow (3/2) remains observable.
        progress_period_start:
          type: [string, 'null']
          format: date-time
          description: >
            A quota's period anchor (§5) - the UTC instant the current period
            began, by the user's local calendar (00:00 on the user's first day
            of the week for a week - see the `week_start` preference, Sunday by
            default - the 1st for a month). Stamped when the row becomes a quota
            (create, or an edit that converts it) and re-stamped to the current
            unit's start when its period (FREQ or INTERVAL) changes - the edit
            keeps only the taps logged since that start, and its undo restores
            the old rule and anchor together (2026-10-16). Null on anything that
            is not a quota (and on a quota made before 2026-10-16 until the
            period-rollover job first runs for it). USE THIS, NOT
            due_at, for anything that needs to know how far through its period a
            quota is (a pace indicator, for example): a quota has no due_at.
        progress_period_end:
          type: [string, 'null']
          format: date-time
          description: >
            Interval quotas (2026-10-16), computed: the CURRENT period's end
            (UTC, exclusive) in the owner's clock - `progress_period_start`
            plus `period_interval` units, stepped past a period that has ended
            but the rollover job has not closed yet. Use it rather than
            re-deriving the window from the rrule. Null on anything that is not
            a quota.
        period_interval:
          type: [integer, 'null']
          description: >
            Computed: the quota rule's INTERVAL - 2 for `FREQ=WEEKLY;INTERVAL=2`
            ("every 2 weeks"), 1 when the rule has none. Null on anything that
            is not a quota.
        period_last_day:
          type: [string, 'null']
          format: date
          description: >
            Computed: the current period's last local day (`YYYY-MM-DD`) -
            `progress_period_end` minus one day, since the end is the next
            period's first midnight. For a client's own compact date. Null on
            anything that is not a quota.
        period_label:
          type: [string, 'null']
          description: >
            Computed: when an interval quota's period ends, as words - "by Oct
            24" (the last day). Only for `period_interval` above 1; NULL at
            interval 1, where a client keeps its own period words ("this week",
            "today") - a two-week period cannot be called "this week". Null on
            anything that is not a quota.
          example: by Oct 24
        quota_prompt_config:
          type: [object, 'null']
          description: >
            Quota reminders (2026-09-24): whether and where this quota prompts
            on the Reminders surface. Null = the defaults (on for daily, weekly
            and monthly quotas; off for yearly; in the user's default period).
            Writable on create and PATCH; `null` resets it. Deleting a period
            (DELETE /api/time-slots/{id}) repoints every stored id naming it —
            here and the user's default — to the remaining period nearest it,
            where its reminders go, in the same undo entry. An id that still
            names a missing period (from before 2026-09-25) falls back to the
            user's default, then the first period of the day. A write that names
            a slot id the stored config does not already name must name one of
            the quota owner's own periods (400 otherwise).
          properties:
            enabled:
              type: boolean
            slot_id:
              type: [integer, 'null']
            numbers:
              type: object
              description: Daily quotas — the period for prompt number k, keyed "1".."N".
              additionalProperties:
                type: [integer, 'null']
        quota_day_state:
          type: [object, 'null']
          readOnly: true
          description: >
            Server-owned record of the owner's local day for this quota's
            prompts; restored by undo. A date other than today means nothing
            happened today.
          properties:
            date:
              type: string
              example: '2026-09-24'
            logged:
              type: integer
            did:
              type: array
              items:
                type: string
            considered:
              type: array
              items:
                type: string
            did_applied:
              type: object
              additionalProperties:
                type: integer
              description: >
                What each key in `did` added to the count — what putting that
                prompt back takes away. A key with no entry (a did-it from
                before 2026-09-25) reads as 1 for a once-a-day prompt and 0
                for a daily numbered one.
        completion_count:
          type: integer
        skip_count:
          type: integer
          description: >
            Occurrences declined without a completion (§7.5). Exists so
            completion_count stays honest - nothing may infer intent from it.
        first_completed_at:
          type: [string, 'null']
          format: date-time
        last_completed_at:
          type: [string, 'null']
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        is_recurring:
          type: boolean
          description: Computed field — true if rrule is set

    TaskCreate:
      type: object
      required: [title]
      properties:
        title:
          type: string
        target_at:
          type: [string, 'null']
          format: date-time
          description: The target (primary name). `due_at` is accepted as its deprecated alias; sending both with different values is a 400.
        deadline_at:
          type: [string, 'null']
          format: date-time
        horizon:
          type: [string, 'null']
          enum: [days, weeks, months, eventually, null]
        short_title:
          type: [string, 'null']
          maxLength: 24
          description: >
            Optional short label for the quota widget chip (§5). Trimmed;
            empty string is stored as null.
            Kept for API compatibility; the web editor no longer sets it, and
            clients fall back to `title` when it is null.
        due_at:
          type: string
          format: date-time
        priority:
          type: integer
          minimum: 0
          maximum: 4
          default: 0
        rrule:
          type: [string, 'null']
          description: |
            RFC 5545 recurrence rule.

            Every repeating task or reminder has a time of day (2026-10-12). A
            rule sent without `BYHOUR` is stored with `BYHOUR`/`BYMINUTE` added:
            the local time of a `due_at` sent in the same request, else the
            user's Morning time (`morning_time` in the user preferences) - e.g.
            `FREQ=DAILY` becomes `FREQ=DAILY;BYHOUR=8;BYMINUTE=30`. A quota
            (`progress_target` > 1 or `is_tracked`) is exempt: its rule is a bare
            period rule (`FREQ=WEEKLY`) and is stored as sent.

            A quota's period may span several units (interval quotas,
            2026-10-16): `FREQ=WEEKLY;INTERVAL=2` is "every 2 weeks". INTERVAL
            comes AFTER FREQ (the reverse order is a 400) and is capped for a
            quota: 1-30 days, 1-12 weeks, 1-12 months; a yearly quota is every
            year (a 400 past that, on create and on any edit that sends the
            rule).

            On a reminder (`is_reminder: true`), OMITTING `rrule` (with no
            `due_at`) puts the reminder in the user's default reminder slot -
            daily at that slot's start, e.g. `FREQ=DAILY;BYHOUR=9;BYMINUTE=0` -
            so it is scheduled immediately, never "Anytime". A title-only
            reminder is still sent to AI enrichment (when AI is on), which moves
            it if the text names a time or cadence. An explicit `rrule: null`
            means "no schedule" (a one-time reminder), and a supplied rule is
            used as sent (with its time filled in, as above). A user with no
            time slots gets no default.
        recurrence_mode:
          type: string
          enum: [from_due, from_completion]
          default: from_due
        labels:
          type: array
          items:
            type: string
        notes:
          type: string
        project_id:
          type: integer
          description: Defaults to user's Inbox project
        is_reminder:
          type: boolean
          default: false
          description: |
            Create a reminder (a thought prompted in a time slot) rather than a
            task. The minimal request - e.g. from an Apple Shortcut - is
            `{"title": "...", "is_reminder": true}`: it lands in the user's
            default reminder slot (see `rrule`) and is enriched by AI when AI
            is enabled. Cannot be combined with quota tracking.
        auto_snooze_minutes:
          type: integer
          minimum: 0
          maximum: 1440
        progress_target:
          type: integer
          minimum: 1
          maximum: 1000
          default: 1
          description: >
            Set above 1 to track this task as a quota (§5); `is_tracked: true`
            makes a quota with a target of 1. A quota must name its period in
            the same request — `rrule` FREQ=DAILY, WEEKLY, MONTHLY or YEARLY —
            or the create is a 400 (2026-09-25).
        create_label:
          type: boolean
          description: >
            Register any labels in this write that aren't already in the
            registry (§7.2). Without it, an unknown label is a 400 - creating a
            label is a discrete act, so a typo fails loudly.
        ai_proposed:
          type: boolean
          description: >
            Apply the `ai-proposed` provenance label. Exists so automated
            callers never type a behavior-bearing label as free text.
        ai_added:
          type: boolean
          description: Apply the `ai-added` provenance label.
        enrich:
          type: boolean
          description: >
            Ask for AI enrichment even though this request supplies structured
            fields. Enrichment is normally triggered only by title-only input
            (a reminder's server-applied default schedule does not count
            against that). The Reminders quick add sets it. Ignored when AI is
            disabled.

    Label:
      type: object
      description: A registered label (§7.2).
      properties:
        id:
          type: integer
        user_id:
          type: integer
        name:
          type: string
        facet:
          type: string
          enum: [domain, operational]
        icon:
          type: [string, 'null']
        color:
          type: [string, 'null']
        created_at:
          type: string
          format: date-time

    TimeSlot:
      type: object
      description: A life-moment container (§6.0).
      properties:
        id:
          type: integer
        user_id:
          type: integer
        label:
          type: string
          example: Early morning
        start_time:
          type: string
          example: '07:00'
          description: HH:MM, 24-hour, local
        sort_order:
          type: integer
          description: Vestigial - every reader orders slots by start_time.
        created_at:
          type: string
          format: date-time

    TimeSlotChange:
      type: object
      description: Result of PATCH/DELETE /api/time-slots/{id}.
      properties:
        slot:
          $ref: '#/components/schemas/TimeSlot'
        reminders_moved:
          type: integer
          description: Reminders rewritten to stay in (or find) a slot.
        quotas_moved:
          type: integer
          description: >
            Quotas whose stored prompt period named a deleted slot and now name
            the nearest remaining one (DELETE only; always 0 for PATCH — a
            retime keeps the slot's id, so its prompts follow it).
        undo_id:
          type: [integer, 'null']
          description: The undo_log entry for this change (POST /api/undo/batch with through_id undoes exactly it); null for a rename.

    ProcessStaleTarget:
      type: object
      properties:
        task: { $ref: '#/components/schemas/Task' }
        attempts:
          type: integer
          description: >
            Local days on which the task was over target (3 or more; spec §7). Replaced
            `age_days` (days since the first target) on 2026-10-12.
    TaskUpdate:
      type: object
      properties:
        title:
          type: string
        short_title:
          type: [string, 'null']
          maxLength: 24
          description: >
            Optional short label for the quota widget chip (§5). Trimmed;
            empty string is stored as null.
            Kept for API compatibility; the web editor no longer sets it, and
            clients fall back to `title` when it is null.
        due_at:
          type: [string, 'null']
          format: date-time
          description: |
            Moving an existing date is a **move of the target** (2026-10-12,
            spec §6 — "Snooze" in the UI; there is no snooze state): the
            first-target date (`original_due_at`) is kept, a repeating task's
            `occurrence_at` is untouched, and the move stops at the deadline
            (`capped_at_deadline: true` in the response) while the deadline is
            ahead; once it has passed, any target can be picked. `null` removes the target —
            on a one-off with a deadline it puts the deadline's automatic target
            back ("Back to auto").

            A move of a dated reminder is refused (400) — reminders are not
            snoozed; an explicit reschedule (`reset_original_due_at: true`) is
            allowed. Sent together with `rrule`, the date is honored and the
            rule's anchors are derived from it (a re-schedule). A quota has no
            due date.
        target_at:
          type: [string, 'null']
          format: date-time
          description: The target (primary name). `due_at` is accepted as its deprecated alias; sending both with different values is a 400.
        deadline_at:
          type: [string, 'null']
          format: date-time
        horizon:
          type: [string, 'null']
          enum: [days, weeks, months, eventually, null]
        retarget:
          type: boolean
          deprecated: true
          description: Alias of `reset_original_due_at`. Accepted and ignored on a task since 2026-10-12 (a move is a move).
        deadline_nag_off:
          type: boolean
          deprecated: true
          description: >
            Accepted from old clients and ignored since 2026-10-12 (spec §8). An old "Remove target" sends it with `target_at: null`, which on a deadline task now puts the automatic target back.
        affirm_horizon:
          type: boolean
          description: >
            Keep the current horizon (an unset horizon stays unset) and stamp `growth_kept_at` = now, so Process asks to grow it again only one threshold period later (ruling Q7, 2026-10-12; it used to restart `horizon_set_at`). Process's "keep" answer to a growth ask. Ignored when the same request changes `horizon`. Also accepted in bulk edit `changes`.
        allow_past_deadline:
          type: boolean
          deprecated: true
          description: |
            Accepted from old clients and ignored since 2026-10-12: every move
            stops at a deadline still ahead (spec §8.2); there is no "past the
            deadline anyway" while it is ahead. Once it has passed the cap is
            off on its own.
        cap_at_deadline:
          type: boolean
          description: |
            Every move stops at the deadline since 2026-10-12, so this no
            longer changes where the target lands. It marks the caller as a
            native quick snooze (the notification content extension's custom
            time), whose capped move also gets the quiet "Snoozed to deadline"
            push (ruling A3), since that surface shows nothing itself.
        reset_original_due_at:
          type: boolean
          description: |
            On a REMINDER: an explicit reschedule (what a period move sends) —
            the new `due_at` becomes its first-target date and occurrence.
            Ignored on a task since 2026-10-12 (a move is a move).
        priority:
          type: integer
          minimum: 0
          maximum: 4
        rrule:
          type: [string, 'null']
          description: |
            RFC 5545 recurrence rule. A NEW rule sent without `BYHOUR` on a task
            or reminder is stored with `BYHOUR`/`BYMINUTE` added (2026-10-12):
            the local time of a `due_at` sent with it, else the task's
            `anchor_time`, else its current `due_at`'s time, else the user's
            Morning time. A rule identical to the stored one is no change. A
            quota's bare period rule is stored as sent - judged on the task the
            request leaves (a request that makes it a quota keeps the bare rule).
        recurrence_mode:
          type: string
          enum: [from_due, from_completion]
        labels:
          type: array
          items:
            type: string
        notes:
          type: [string, 'null']
        project_id:
          type: integer
        auto_snooze_minutes:
          type: integer
          minimum: 0
          maximum: 1440
        progress_target:
          type: integer
          minimum: 1
          maximum: 1000
          description: >
            A quota always has a period (2026-09-25): a PATCH whose result is a
            quota without a period rule — `rrule: null` on a quota, or a target
            above 1 / `is_tracked: true` on a task with no FREQ=DAILY, WEEKLY,
            MONTHLY or YEARLY rule — is a 400.
        progress_current:
          type: integer
          minimum: 0
        create_label:
          type: [boolean, 'null']
          description: Register unknown labels in this write instead of rejecting them (§7.2).

    Project:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        owner_id:
          type: integer
        shared:
          type: boolean
        sort_order:
          type: integer
        color:
          type: [string, 'null']
          enum: [red, orange, yellow, green, blue, purple, pink, gray, null]
        description:
          type: [string, 'null']
          maxLength: 280
          description: >-
            What belongs in this project (at most 280 characters), set in
            Settings or over the API; null when unset. Read it to choose a
            project for a new task.
        active_count:
          type: integer
          description: The caller's own open tasks in the project, quotas excluded
        overdue_count:
          type: integer
          description: >-
            The caller's own tasks in the project that are due right now (the
            same rule as the badge); reminders and quotas are never counted
        created_at:
          type: string
          format: date-time

    Completion:
      type: object
      properties:
        id:
          type: integer
        task_id:
          type: integer
        user_id:
          type: integer
        completed_at:
          type: string
          format: date-time
        due_at_was:
          type: [string, 'null']
          format: date-time
        due_at_next:
          type: [string, 'null']
          format: date-time
        attempts:
          type: [integer, 'null']
          description: >
            The task's attempts (local days over target, spec §7) when it was completed — a late
            occurrence shows how late. Null on rows from before 2026-10-12.
        task_title:
          type: string
        project_id:
          type: integer
        is_reminder:
          type: boolean
        is_tracked:
          type: boolean
        progress_target:
          type: integer

    Webhook:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
        url:
          type: string
          format: uri
        secret:
          type: string
          description: HMAC-SHA256 signing secret (only returned on creation)
        events:
          type: array
          items:
            type: string
            enum:
              - task.created
              - task.updated
              - task.completed
              - task.deleted
              - task.snoozed
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookPublic:
      type: object
      description: Webhook without the secret field
      properties:
        id:
          type: integer
        user_id:
          type: integer
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
            enum:
              - task.created
              - task.updated
              - task.completed
              - task.deleted
              - task.snoozed
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - task.created
              - task.updated
              - task.completed
              - task.deleted
              - task.snoozed

    WebhookUpdate:
      type: object
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - task.created
              - task.updated
              - task.completed
              - task.deleted
              - task.snoozed
        active:
          type: boolean

    WebhookDelivery:
      type: object
      properties:
        id:
          type: integer
        webhook_id:
          type: integer
        event:
          type: string
        payload:
          type: string
          description: JSON string of the delivered payload
        status_code:
          type: [integer, 'null']
        error:
          type: [string, 'null']
        attempt:
          type: integer
        created_at:
          type: string
          format: date-time

    UndoResult:
      type: object
      properties:
        undone_action:
          type: string
        description:
          type: [string, 'null']
        tasks_affected:
          type: integer
        undoable_count:
          type: integer
          description: Entries left to undo (scoped by `session_start_id` when sent)
        redoable_count:
          type: integer
          description: Entries left to redo (scoped by `session_start_id` when sent)

    UndoSessionScope:
      type: object
      properties:
        session_start_id:
          type: integer
          description: |
            Count only undo-log entries newer than this id in the returned
            `undoable_count` and `redoable_count`. Omitted = all entries.

    RedoResult:
      type: object
      properties:
        redone_action:
          type: string
        description:
          type: [string, 'null']
        tasks_affected:
          type: integer
        undoable_count:
          type: integer
        redoable_count:
          type: integer

    UndoBatchResult:
      type: object
      properties:
        count:
          type: integer
          description: Entries undone (or redone)
        undoable_count:
          type: integer
          description: Entries left to undo (all time)
        redoable_count:
          type: integer
          description: Entries left to redo (all time)

    UndoHistoryEntry:
      type: object
      properties:
        id:
          type: integer
        action:
          type: string
          enum:
            - done
            - undone
            - snooze
            - edit
            - delete
            - create
            - restore
            - bulk_done
            - bulk_snooze
            - bulk_edit
            - bulk_delete
            - progress
            - skip
            - bulk_skip
            - time_slot_edit
            - time_slot_delete
            - quota_prompt
        description:
          type: [string, 'null']
        tasks_affected:
          type: integer
          description: Number of task snapshots in the entry
        created_at:
          type: string
          format: date-time
        undone:
          type: boolean
        fields_changed:
          type: array
          items:
            type: string
        snapshot:
          type: array
          description: One entry per affected task.
          items:
            type: object
            properties:
              task_id:
                type: integer
              before_state:
                type: object
                description: The changed task fields before the action (a subset of `Task`).
                additionalProperties: true
              after_state:
                type: object
                description: The changed task fields after the action (a subset of `Task`).
                additionalProperties: true
              completion_id:
                type: integer
                description: The completion record this entry created, if any.

    DailyStat:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
        date:
          type: string
          format: date
          description: YYYY-MM-DD in the user's timezone
        completions:
          type: integer
        tasks_created:
          type: integer
        snoozes:
          type: integer

    AISlotStats:
      type: object
      description: A warm model slot (server-wide, not per user).
      properties:
        state:
          type: string
          enum: [uninitialized, initializing, available, busy, dead]
        activatedAt:
          type: [string, 'null']
        totalRequests:
          type: integer
        totalRecycles:
          type: integer
        lastRequestAt:
          type: [string, 'null']
        model:
          type: string

    AIActivityEntry:
      type: object
      description: One row of the user's AI activity log.
      properties:
        id:
          type: integer
        user_id:
          type: integer
        task_id:
          type: [integer, 'null']
        action:
          type: string
        status:
          type: string
          enum: [success, error, skipped]
        input:
          type: [string, 'null']
        output:
          type: [string, 'null']
          description: Raw model output (usually a JSON string)
        model:
          type: [string, 'null']
        duration_ms:
          type: [integer, 'null']
        error:
          type: [string, 'null']
        provider:
          type: [string, 'null']
        created_at:
          type: string

    InsightsSignalKey:
      type: string
      enum: [review, stale, act_soon, quick_win, vague, misprioritized]

    BulkResult:
      type: object
      properties:
        tasks_affected:
          type: integer
          description: Number of tasks that were modified
        tasks_skipped:
          type: integer
          description: Number of tasks in the request that were not modified
        skipped_urgent:
          type: integer
          description: |
            Skipped on priority — High AND Urgent together. The name is kept for
            client compatibility (the iOS app reads it).
        skipped_high:
          type: integer
          description: |
            The High (P3) subset of `skipped_urgent`. Urgent alone is the
            difference between the two.
        snoozed_high:
          type: integer
          description: |
            How many of the tasks actually moved were High (P3). Equal to
            `tasks_affected` on the second press of a double snooze, when only
            High tasks were left to sweep.
        skipped_reminders:
          type: integer
          description: Reminders skipped — they are bucket-locked and never snoozed
        skipped_deadline:
          type: integer
          description: Held back like High because their deadline has passed (2026-10-12) — below P3 by priority, swept once nothing lower is left in the batch. Not in `skipped_urgent`. In bulk edit, the tasks whose date part was withheld for that reason.
        capped_deadline:
          type: integer
          description: Moved only as far as their deadline (2026-10-12, spec §8.2) — every move stops at it. Also counted in `tasks_affected`.
        skipped_no_due_date:
          type: integer
          description: Relative mode only — tasks with no due_at to add the delta to

    StatsSummary:
      type: object
      properties:
        today:
          description: Today's stats row (in the user's timezone), or null when there has been no activity today.
          oneOf:
            - $ref: '#/components/schemas/DailyStat'
            - type: 'null'
        week:
          type: object
          properties:
            completions:
              type: integer
            tasks_created:
              type: integer
            snoozes:
              type: integer
        month:
          type: object
          properties:
            completions:
              type: integer
            tasks_created:
              type: integer
            snoozes:
              type: integer
        all_time:
          type: object
          properties:
            completions:
              type: integer
            tasks_created:
              type: integer
            snoozes:
              type: integer
