openapi: 3.1.0
info:
  title: TapTidy Task Management API
  version: 1.0.0
  description: |
    REST API for TapTidy task management system with support for tasks, projects, and sync operations.

    **Contract Stability**: This specification follows additive-only versioning. Fields may be added but never removed or renamed.
    Breaking changes require a new API version.
  contact:
    name: TapTidy API Support
  license:
    name: MIT
    identifier: MIT

servers:
  - url: http://localhost:3000
    description: Local development server
  - url: https://api.taptidy.app
    description: Production server

tags:
  - name: tasks
    description: Task CRUD operations
  - name: projects
    description: Project (list) management operations
  - name: shares
    description: Live sharing and collaboration
  - name: auth
    description: Authentication and session management
  - name: household
    description: Household management, tasks, routines, and child accounts
  - name: templates
    description: Template CRUD and task instantiation operations
  - name: fcm
    description: Firebase Cloud Messaging device token registration
  - name: unified-push
    description: UnifiedPush endpoint registration for FCM-free push notifications
  - name: providers
    description: External provider integrations (Todoist, CalDAV)
  - name: tags
    description: Tag CRUD and hierarchy management
  - name: stats
    description: Task completion statistics and analytics
  - name: routines
    description: Focus routines and time-tracking sessions
  - name: focus
    description: Focus recommendations and Today top-3 attribution
  - name: views
    description: Custom task views (list, kanban, calendar)
  - name: conflicts
    description: Sync conflict detection and resolution
  - name: settings
    description: User settings and app passwords
  - name: email-settings
    description: Email and SMTP configuration
  - name: notifications
    description: Web push notification subscriptions
  - name: sync
    description: Sync status and outbox management
  - name: safety
    description: Safety-related endpoints and scoring
  - name: admin
    description: Administrative operations (admin role required)
  - name: sections
    description: Project task sections
  - name: filters
    description: Saved task filters
  - name: timers
    description: Pomodoro and manual time-tracking sessions
  - name: journals
    description: Per-task journal entries
  - name: activity
    description: Task and user activity feeds
  - name: comments
    description: Per-task comments (Pro tier)
  - name: suggestions
    description: Task suggestions based on completion patterns
  - name: import
    description: Bulk task import from external services
  - name: backup
    description: Data export and import (backup/restore)
  - name: email-handles
    description: Inbound email handle management (claim, verify, retrieve)
  - name: inbound-email
    description: Inbound email webhook receiver (Resend/Svix)
  - name: captures
    description: Capture review engine — confidence scoring, eligibility, Catch Up actions, and unified inbox triage
  - name: debug
    description: Development and diagnostics endpoints (not available in production; requires TAPTIDY_DEV_AUTH=1)
  - name: e2ee
    description: End-to-end encryption key management and encrypted task operations
  - name: zapier
    description: Zapier webhook integration — subscriptions, inbound triggers, delivery logs
  - name: webhook-signing-secret
    description: Per-account HMAC signing secret for outgoing webhook/socket event authenticity (#1863)
  - name: eso-bridge
    description: Internal service-to-service API for E-SO (vsa.im) integration. Authenticated via shared secret, not public.
  - name: ai
    description: AI-powered features — task breakdown, provider listing, telemetry
  - name: shopping
    description: Shopping list management — categorized items, batch operations, sharing, real-time sync
  - name: notes
    description: Standalone notes CRUD
  - name: library
    description: Note category management
  - name: nudges
    description: Contextual nudge delivery and dismissal
  - name: contact
    description: Public contact/feedback form (no auth required)
  - name: releases
    description: App release/version information (no auth required)
  - name: waitlist
    description: Early access waitlist signup (no auth required)
  - name: email-analytics
    description: Email delivery analytics and bounce/complaint tracking
  - name: productivity
    description: High-value productivity features (Roulette, Capacity, Dependencies)

security:
  - bearerAuth: []
  - basicAuth: []
  - devAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    basicAuth:
      type: http
      scheme: basic
    devAuth:
      type: http
      scheme: bearer
      description: Development-only auth using "Bearer dev-{userId}" tokens
    patAuth:
      type: http
      scheme: bearer
      description: Personal access token ("Bearer taptidy_pat_<id>.<secret>") issued via POST /api/v1/settings/personal-access-tokens
    esoBridgeKey:
      type: apiKey
      in: header
      name: x-eso-bridge-key
      description: Shared secret for E-SO ↔ TapTidy internal bridge (ESO_BRIDGE_SECRET)

  schemas:
    # Core alarm/reminder schema
    Alarm:
      type: object
      description: |
        iCalendar-style alarm trigger for task reminders.

        **Trigger formats**:
        - `PT0S`: At due time (for all-day tasks, uses user's allDayReminderHour/Minute setting)
        - `-PT15M`: 15 minutes before due time
        - `-P1D`: 1 day before due date
        - `2026-02-15T10:00:00Z`: Absolute ISO 8601 timestamp
      properties:
        action:
          type: string
          example: DISPLAY
        trigger:
          type: string
          description: iCalendar trigger string (PT0S, -PT15M, or ISO timestamp)
          example: PT0S
        description:
          type: string
          example: Task reminder
        summary:
          type: string
          example: Complete task
        repeat:
          type: integer
          minimum: 0
          example: 0
        duration:
          type: string
          example: PT15M
      example:
        trigger: PT0S
        action: DISPLAY

    # Recurrence rule schema
    RecurrenceRule:
      type: object
      description: Recurrence pattern for repeating tasks (RFC 5545 compatible)
      required:
        - frequency
        - interval
      properties:
        frequency:
          type: string
          enum: [daily, weekly, monthly, yearly]
          example: weekly
        interval:
          type: integer
          minimum: 1
          example: 1
        endDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        count:
          oneOf:
            - type: integer
              minimum: 1
            - type: 'null'
        byDay:
          type: array
          items:
            type: string
            pattern: ^(MO|TU|WE|TH|FR|SA|SU)$
          example: ["MO", "WE", "FR"]
        byMonthDay:
          type: array
          items:
            type: integer
            minimum: 1
            maximum: 31
          example: [1, 15]
        basis:
          type: string
          enum: [scheduled, completed]
          description: >-
            What the next occurrence is anchored on when the task completes.
            'scheduled' (default) preserves the due-date cadence; 'completed'
            schedules the next occurrence relative to the completion time.
          example: scheduled

    # Sync metadata schema
    SyncMetadata:
      type: object
      required:
        - version
        - needsSync
        - lastSyncAt
      properties:
        version:
          type: integer
          description: Optimistic locking version number
          example: 1
        needsSync:
          type: boolean
          description: Whether task has local changes needing sync
          example: false
        lastSyncAt:
          type: string
          format: date-time
          description: Last successful sync timestamp
          example: "2026-02-11T10:00:00Z"

    # Task creation schema
    TaskCreate:
      type: object
      required:
        - title
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
          example: Buy groceries
        description:
          oneOf:
            - type: string
              maxLength: 10000
            - type: 'null'
          example: Milk, eggs, bread
        priority:
          type: integer
          minimum: 0
          maximum: 4
          default: 0
          description: |
            Task priority level using canonical 0-4 scale:
            - `0`: None (no priority set)
            - `1`: Highest priority
            - `2`: High priority
            - `3`: Normal priority
            - `4`: Lowest priority
            
            **Provider Mappings**:
            - Todoist (1-4): Maps inversely (TapTidy 1→Todoist 4, TapTidy 4→Todoist 1)
            - CalDAV RFC 5545 (0-9): TapTidy 0→0, 1→1, 2→3, 3→5, 4→9
          example: 2
        status:
          type: string
          enum: [todo, in_progress, waiting, done, archived]
          default: todo
          description: Task workflow status
          example: todo
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Due date/time as UTC ISO 8601 timestamp (for timed tasks)
          example: "2026-02-15T14:00:00Z"
        dueDateRaw:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
          description: Due date as YYYY-MM-DD string (for all-day tasks)
          example: "2026-02-15"
        tags:
          type: array
          items:
            type: string
            maxLength: 50
          default: []
          example: ["shopping", "urgent"]
        projectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          example: "550e8400-e29b-41d4-a716-446655440000"
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          description: Parent task ID for subtasks
        recurrence:
          oneOf:
            - $ref: '#/components/schemas/RecurrenceRule'
            - type: string
              description: RRULE string (legacy format)
            - type: 'null'
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Snooze task until this timestamp
        alarms:
          type: array
          items:
            $ref: '#/components/schemas/Alarm'
          maxItems: 20
          default: []
          description: Reminder alarms for this task
        usesDefaultReminderTime:
          type: boolean
          description: >-
            Client-computed "this alarm is the default" flag. Only honored when
            alarms is non-empty; the server computes it independently otherwise.
        estimatedMinutes:
          type: integer
          minimum: 0
          maximum: 10080
          description: User-set estimated effort / duration in minutes
        encryptedTitle:
          oneOf:
            - type: string
            - type: 'null'
          description: AES-256-GCM encrypted title as JSON envelope {iv, ciphertext}. Mutually exclusive with plaintext title for E2EE-enabled accounts.
        encryptedDescription:
          oneOf:
            - type: string
            - type: 'null'
          description: AES-256-GCM encrypted description as JSON envelope.
        encryptionVersion:
          oneOf:
            - type: integer
            - type: 'null'
          description: Encryption scheme version. 1 = AES-256-GCM with Android Keystore DEK.
        isShoppingItem:
          type: boolean
          default: false
          description: Whether this task is a shopping list item
        groceryCategory:
          oneOf:
            - type: string
              maxLength: 50
            - type: 'null'
          description: "Shopping item category (produce|dairy|bakery|meat|pantry|frozen|household|other)"
        notifyOverrideQuietHours:
          type: boolean
          default: false
          description: "Always notify for this task, even during quiet hours. Off by default."

    # Inbound webhook task creation (Home Assistant & third-party automations)
    InboundTaskWebhookRequest:
      type: object
      required:
        - title
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
          example: Take out the trash
        description:
          type: string
          maxLength: 10000
          example: Bin day tomorrow
        userId:
          type: string
          format: uuid
          description: Optional — must match the authenticated user when supplied
        priority:
          type: integer
          minimum: 0
          maximum: 4
          default: 0
          example: 2
        dueDate:
          type: string
          format: date-time
          description: Due date/time as UTC ISO 8601 timestamp
          example: "2026-02-15T14:00:00Z"
        tags:
          type: array
          items:
            type: string
            maxLength: 50
          default: []
          example: ["chores"]
        projectId:
          type: string
          format: uuid
          description: Target project — must belong to the authenticated user; defaults to Inbox when omitted
        assigneeMemberId:
          type: string
          minLength: 1
          maxLength: 100
          description: Household member ID to assign the task to — must be a member of the caller's household

    # Task update schema (all fields optional and nullable)
    TaskUpdate:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
        description:
          oneOf:
            - type: string
              maxLength: 10000
            - type: 'null'
        completed:
          type: boolean
        completedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: >-
            Client's local completion moment for offline-synced completions.
            Anchors basis:'completed' recurrence server-side (sanity-bounded;
            falls back to server time when absent or implausible).
        status:
          type: string
          enum: [todo, in_progress, waiting, done, archived]
          description: Task workflow status
        priority:
          type: integer
          minimum: 0
          maximum: 4
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        dueDateRaw:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
        tags:
          type: array
          items:
            type: string
            maxLength: 50
        projectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        recurrence:
          oneOf:
            - $ref: '#/components/schemas/RecurrenceRule'
            - type: string
            - type: 'null'
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        alarms:
          oneOf:
            - type: array
              items:
                $ref: '#/components/schemas/Alarm'
              maxItems: 20
            - type: 'null'
        estimatedMinutes:
          oneOf:
            - type: integer
              minimum: 0
              maximum: 10080
            - type: 'null'
          description: User-set estimated effort / duration in minutes
        encryptedTitle:
          oneOf:
            - type: string
            - type: 'null'
        encryptedDescription:
          oneOf:
            - type: string
            - type: 'null'
        encryptionVersion:
          oneOf:
            - type: integer
            - type: 'null'
        isShoppingItem:
          type: boolean
          default: false
          description: Whether this task is a shopping list item
        groceryCategory:
          oneOf:
            - type: string
              maxLength: 50
            - type: 'null'
          description: "Shopping item category (produce|dairy|bakery|meat|pantry|frozen|household|other)"
        notifyOverrideQuietHours:
          type: boolean
          description: "Always notify for this task, even during quiet hours. Off by default."

    # Lightweight task summary (for dependencies and recursive structures)
    TaskSummary:
      type: object
      required:
        - id
        - title
        - completed
        - priority
        - createdAt
        - updatedAt
        - isShoppingItem
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        description:
          oneOf:
            - type: string
            - type: 'null'
        completed:
          type: boolean
        completedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        status:
          oneOf:
            - type: string
              enum: [todo, in_progress, waiting, done, archived]
            - type: 'null'
        priority:
          type: integer
          minimum: 0
          maximum: 4
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        dueDateRaw:
          oneOf:
            - type: string
            - type: 'null'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        order:
          type: integer
        tags:
          type: array
          items:
            type: string
        projectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        isShoppingItem:
          type: boolean

    # Task response schema (canonical representation)
    TaskAttachment:
      type: object
      description: Metadata for a file attached to a task (issue #604).
      required: [id, taskId, filename, mimeType, sizeBytes, createdAt, uploadedByUserId]
      properties:
        id:
          type: string
          format: uuid
        taskId:
          type: string
          format: uuid
        filename:
          type: string
          description: Sanitized display filename (storage name is server-generated).
        mimeType:
          type: string
        sizeBytes:
          type: integer
        createdAt:
          type: string
          format: date-time
        uploadedByUserId:
          type: string

    TaskAttachmentListResponse:
      type: object
      required: [attachments]
      properties:
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/TaskAttachment'

    TaskResponse:
      type: object
      required:
        - id
        - title
        - completed
        - priority
        - createdAt
        - updatedAt
        - order
        - tags
        - alarms
        - syncMetadata
        - isShoppingItem
      properties:
        id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        title:
          type: string
          example: Buy groceries
        description:
          oneOf:
            - type: string
            - type: 'null'
          example: Milk, eggs, bread
        completed:
          type: boolean
          example: false
        completedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          example: "2026-02-11T10:00:00Z"
        status:
          type: string
          enum: [todo, in_progress, waiting, done, archived]
          description: Task workflow status
          example: todo
        archivedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Timestamp when the task was archived
        clarityScore:
          oneOf:
            - type: number
              format: float
              minimum: 0
              maximum: 1
            - type: 'null'
          description: Clarity coaching score (0-1)
        priority:
          type: integer
          minimum: 0
          maximum: 4
          example: 2
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Due timestamp for timed tasks (mutually exclusive with dueDateRaw)
          example: "2026-02-15T14:00:00Z"
        dueDateHasTime:
          type: boolean
          description: Whether dueDate carries an explicit time component (false for all-day tasks)
        dueDateRaw:
          oneOf:
            - type: string
            - type: 'null'
          description: Due date for all-day tasks as YYYY-MM-DD (mutually exclusive with dueDate)
          example: "2026-02-15"
        createdAt:
          type: string
          format: date-time
          example: "2026-02-10T10:00:00Z"
        updatedAt:
          type: string
          format: date-time
          example: "2026-02-11T10:00:00Z"
        order:
          type: integer
          description: Sort order for manual task ordering
          example: 0
        tags:
          type: array
          items:
            type: string
          example: ["shopping", "urgent"]
        projectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          example: "550e8400-e29b-41d4-a716-446655440000"
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        recurrence:
          oneOf:
            - $ref: '#/components/schemas/RecurrenceRule'
            - type: 'null'
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        alarms:
          type: array
          items:
            $ref: '#/components/schemas/Alarm'
          description: Reminder alarms (matches Android "alarms" field naming)
        usesDefaultReminderTime:
          type: boolean
          description: True when alarms was auto-populated from the account's defaultReminderMinutesBefore setting rather than set explicitly
        estimatedMinutes:
          oneOf:
            - type: integer
              minimum: 0
              maximum: 10080
            - type: 'null'
          description: User-set estimated effort / duration in minutes
        syncMetadata:
          $ref: '#/components/schemas/SyncMetadata'
        source:
          type: string
          description: Task source (taptidy, todoist, caldav, etc.)
          example: taptidy
        sourceMeta:
          oneOf:
            - type: object
              additionalProperties: true
            - type: 'null'
          description: >-
            Provenance for integration-created tasks (#1014): how the task
            entered TapTidy, e.g. via an app password from Raycast/Zapier.
            Keys: createdVia, sourceType, sourceName, externalId,
            appPasswordId, receivedAt. Never contains the app-password token.
          example:
            createdVia: app-password
            sourceName: Work laptop
            sourceType: Raycast
            receivedAt: '2026-06-14T17:00:00.000Z'
        shareRole:
          oneOf:
            - type: string
              enum: [owner, editor, viewer]
            - type: 'null'
          description: Current user's role if task is shared (null for owned tasks)
          example: editor
        shareStatus:
          oneOf:
            - type: string
              enum: [pending, accepted, declined, revoked, archived]
            - type: 'null'
          description: Share status if task is shared (null for owned tasks)
          example: accepted
        householdId:
          oneOf:
            - type: string
            - type: 'null'
          description: Household this task belongs to (null for personal tasks)
          example: "550e8400-e29b-41d4-a716-446655440000"
        householdTaskKind:
          oneOf:
            - type: string
            - type: 'null'
          description: Household task kind (e.g. shared, errand); null for personal tasks
          example: shared
        householdCalendarKind:
          oneOf:
            - type: string
              enum: [shared, errand, chore]
            - type: 'null'
          description: Canonical household calendar classification derived from task kind and chore assignments
          example: chore
        assigneeId:
          oneOf:
            - type: string
            - type: 'null'
          description: Canonical household member ID directly assigned to this task; null for unassigned or personal tasks
        requiresApproval:
          type: boolean
          description: Whether completion of this household task requires adult approval
          default: false
        effortWeight:
          type: integer
          minimum: 1
          maximum: 999
          description: Household effort weight used by client-side policy evaluation
          default: 1
        taskTier:
          type: string
          enum: [baseline, hustle]
          description: Household economy tier used by client-side policy evaluation
          default: baseline
        routineOccurrenceId:
          oneOf:
            - type: string
            - type: 'null'
          description: ID of the routine occurrence that generated this task (null for hand-made tasks)
          example: "550e8400-e29b-41d4-a716-446655440000"
        needsClarification:
          type: boolean
          description: Flag indicating this task needs clarification before it can be acted on
          example: false
        encryptedTitle:
          oneOf:
            - type: string
            - type: 'null'
          description: AES-256-GCM encrypted title as JSON envelope {iv, ciphertext}. Present when E2EE is enabled.
        encryptedDescription:
          oneOf:
            - type: string
            - type: 'null'
          description: AES-256-GCM encrypted description as JSON envelope.
        encryptionVersion:
          oneOf:
            - type: integer
            - type: 'null'
          description: Encryption scheme version. NULL = plaintext, 1 = AES-256-GCM.
        isShoppingItem:
          type: boolean
          description: Whether this task is a shopping list item
        groceryCategory:
          oneOf:
            - type: string
            - type: 'null'
          description: "Shopping item category (produce|dairy|bakery|meat|pantry|frozen|household|other)"
        notifyOverrideQuietHours:
          type: boolean
          description: "Always notify for this task, even during quiet hours. Off by default."
        isBlocked:
          type: boolean
          description: Whether this task is currently blocked by incomplete dependencies
        dependencies:
          type: array
          items:
            $ref: '#/components/schemas/TaskSummary'
          description: Tasks that this task depends on (blocked by)
        blockedTasks:
          type: array
          items:
            $ref: '#/components/schemas/TaskSummary'
          description: Tasks that depend on this task (blocking)

    # Task list response
    TaskListResponse:
      type: object
      required:
        - tasks
        - total
        - hasMore
      properties:
        tasks:
          type: array
          items:
            $ref: '#/components/schemas/TaskResponse'
        total:
          type: integer
          description: Total number of tasks matching the query
          example: 42
        hasMore:
          type: boolean
          description: Whether more results are available
          example: false
        serverTime:
          type: string
          format: date-time
          description: >-
            Server-issued watermark captured before the query ran. Delta-sync
            clients must persist this value and send it as updatedAfter on the
            next sync instead of computing a watermark from their own clock.
        deletedTaskIds:
          type: array
          items:
            type: string
            format: uuid
          description: >-
            IDs of the requesting user's tasks hard-deleted since updatedAfter.
            Only present on delta requests (updatedAfter set). Clients remove
            these tasks locally instead of waiting for a full pull.

    # Batch operations
    TaskBatchCreate:
      allOf:
        - $ref: '#/components/schemas/TaskCreate'
        - type: object
          properties:
            clientId:
              type: string
              description: Client-generated ID for correlating response
              example: "client-123"

    TaskBatchUpdateItem:
      type: object
      required:
        - id
        - updates
      properties:
        id:
          type: string
          format: uuid
        updates:
          $ref: '#/components/schemas/TaskUpdate'

    TaskBatchRequest:
      type: object
      properties:
        creates:
          type: array
          items:
            $ref: '#/components/schemas/TaskBatchCreate'
          default: []
        updates:
          type: array
          items:
            $ref: '#/components/schemas/TaskBatchUpdateItem'
          default: []
        deletes:
          type: array
          items:
            type: string
            format: uuid
          default: []

    TaskBatchResponse:
      type: object
      required:
        - created
        - updated
        - deleted
        - errors
      properties:
        created:
          type: array
          items:
            type: object
            properties:
              clientId:
                oneOf:
                  - type: string
                  - type: 'null'
              task:
                $ref: '#/components/schemas/TaskResponse'
        updated:
          type: array
          items:
            $ref: '#/components/schemas/TaskResponse'
        deleted:
          type: array
          items:
            type: string
            format: uuid
        errors:
          type: array
          items:
            type: object
            required:
              - op
              - message
            properties:
              op:
                type: string
                enum: [create, update, delete]
              id:
                oneOf:
                  - type: string
                  - type: 'null'
              message:
                type: string

    # Error response
    ErrorResponse:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: string
          enum:
            - VALIDATION_ERROR
            - NOT_FOUND
            - UNAUTHORIZED
            - FORBIDDEN
            - CONFLICT
            - DB_ERROR
            - INTERNAL_ERROR
            - BLOCKED
            - LIMIT_REACHED
            - INVALID_STATE
            - POTENTIAL_DUPLICATE
            - CANCELLED
            - DOMAIN_ERROR
            - SIGNUP_DISABLED
            - USER_EXISTS
            - INVALID_TOKEN
            - TOKEN_EXPIRED
            - TOKEN_INVALID
            - RESEND_RATE_LIMITED
            - HANDLE_COOLDOWN
            - HANDLE_NOT_AVAILABLE
            - HANDLE_PENDING
            - HANDLE_ALREADY_ACTIVE
            - HANDLE_TAKEN
            - NO_PENDING_HANDLE
            - NO_HANDLE
            - HANDLE_RESERVED
            - HANDLE_INVALID
            - DOMAIN_NOT_ALLOWED
          example: VALIDATION_ERROR
          description: |
            Error codes:
            - VALIDATION_ERROR: Request validation failed
            - NOT_FOUND: Resource not found
            - UNAUTHORIZED: Authentication required
            - FORBIDDEN: Insufficient permissions
            - CONFLICT: Resource conflict (e.g., duplicate)
            - DB_ERROR: Database operation failed
            - INTERNAL_ERROR: Internal server error
            - BLOCKED: User has blocked the operation
            - INVALID_STATE: Invalid state transition
            - POTENTIAL_DUPLICATE: Potential duplicate detected
            - CANCELLED: Operation cancelled
            - DOMAIN_ERROR: Domain validation error
            - SIGNUP_DISABLED: User registration disabled
            - USER_EXISTS: User already exists
            - INVALID_TOKEN: Invalid or expired token
        message:
          type: string
          example: Invalid request parameters
        reason:
          type: string
          description: >-
            Optional fine-grained outcome, additive to `code`. Currently emitted
            by household invite accept so clients can render distinct invite
            outcomes while legacy clients keep reading `code`.
          enum:
            - invite_not_found
            - invite_expired
            - invite_used
            - invite_revoked
            - account_mismatch
            - already_member
        details:
          oneOf:
            - type: object
            - type: 'null'
        requestId:
          type: string
          description: Request correlation ID for debugging
          example: "req-abc123"

    BillingNotConfiguredError:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: string
          enum:
            - BILLING_NOT_CONFIGURED
          example: BILLING_NOT_CONFIGURED
        message:
          type: string
          example: Billing checkout is temporarily unavailable

    # Sharing (live collaboration)
    ShareSummary:
      type: object
      required: [shareId, entityType, title, role, status, sharedAt]
      properties:
        shareId:
          type: string
        entityType:
          type: string
          enum: [project, task]
        projectId:
          oneOf:
            - type: string
            - type: 'null'
        taskId:
          oneOf:
            - type: string
            - type: 'null'
        title:
          type: string
        description:
          oneOf:
            - type: string
            - type: 'null'
        inviterName:
          oneOf:
            - type: string
            - type: 'null'
        inviterEmail:
          oneOf:
            - type: string
            - type: 'null'
        role:
          type: string
          enum: [viewer, editor]
        status:
          type: string
          enum: [pending, accepted, declined, revoked, archived]
        sharedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        archivedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        message:
          oneOf:
            - type: string
            - type: 'null'

    SharePreview:
      type: object
      required: [shareId, entityType, role, status]
      properties:
        shareId:
          type: string
        entityType:
          type: string
          enum: [project, task]
        task:
          $ref: '#/components/schemas/SharePreviewTask'
        project:
          $ref: '#/components/schemas/SharePreviewProject'
        inviterName:
          oneOf:
            - type: string
            - type: 'null'
        inviterEmail:
          oneOf:
            - type: string
            - type: 'null'
        role:
          type: string
          enum: [viewer, editor]
        status:
          type: string
          enum: [pending, accepted, declined, revoked, archived]
        duplicates:
          type: array
          items:
            $ref: '#/components/schemas/ShareDuplicate'

    SharePreviewTask:
      type: object
      properties:
        title: { type: string }
        description:
          oneOf:
            - type: string
            - type: 'null'
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        priority: { type: integer, default: 0 }
        tags:
          type: array
          items: { type: string }

    SharePreviewProject:
      type: object
      properties:
        name: { type: string }
        description:
          oneOf:
            - type: string
            - type: 'null'
        color:
          oneOf:
            - type: string
            - type: 'null'

    ShareDuplicate:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        updatedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'

    ShareCreateRequest:
      type: object
      required: [recipientIdentifier, role]
      properties:
        projectId:
          type: string
          description: Required for /shares/projects
        taskId:
          type: string
          description: Required for /shares/tasks
        recipientIdentifier:
          type: string
          description: Email or user id
        role:
          type: string
          enum: [viewer, editor]
          default: editor
        message:
          oneOf:
            - type: string
            - type: 'null'

    ShareRoleUpdateRequest:
      type: object
      required: [role]
      properties:
        role:
          type: string
          enum: [viewer, editor]

    # Project schemas
    ProjectCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          example: Work Tasks
        description:
          oneOf:
            - type: string
              maxLength: 1000
            - type: 'null'
          example: Tasks related to work projects
        color:
          oneOf:
            - type: string
              pattern: ^#[0-9A-Fa-f]{6}$
            - type: 'null'
          example: "#4A90E2"
        icon:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
          description: Lucide icon name (kebab-case) shown alongside the list color
          example: shopping-cart
        projectType:
          oneOf:
            - type: string
              enum: [standard, shopping, packing]
            - type: 'null'
          description: Canonical project classification field. Use this instead of `type`.
          example: shopping
        type:
          type: string
          enum: [standard, shopping, packing]
          deprecated: true
          description: |
            **Deprecated — sunset 2026-07-01.** Use `projectType` instead.
            Currently accepted and coerced to `projectType` for backward compatibility.
            Requests using this field receive `Deprecation: true` and `Sunset` response headers.
            After the sunset date, this field will be rejected with HTTP 400.
        storeLayoutTemplate:
          oneOf:
            - type: string
              enum: [grocery, hardware, general]
            - type: 'null'
          description: Store layout template used by shopping lists.
          example: grocery
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          description: Parent project ID for nested projects
        order:
          type: integer
          minimum: 0
          description: Sort order for manual project ordering
        isArchived:
          type: boolean
          default: false
          description: Whether the project is archived
        syncMode:
          type: string
          enum: [TAPTIDY, CALDAV, LOCAL]
          default: TAPTIDY
        syncAccountId:
          oneOf:
            - type: string
            - type: 'null'
          description: External sync account identifier
        isActive:
          type: boolean
          default: false
          description: Whether a shopping trip is currently in progress

    ProjectUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        description:
          oneOf:
            - type: string
              maxLength: 1000
            - type: 'null'
        color:
          oneOf:
            - type: string
              pattern: ^#[0-9A-Fa-f]{6}$
            - type: 'null'
        icon:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
          description: Lucide icon name (kebab-case) shown alongside the list color
        projectType:
          oneOf:
            - type: string
              enum: [standard, shopping, packing]
            - type: 'null'
          description: Canonical project classification field. Legacy `type` is rejected; use `projectType`.
        remoteId:
          oneOf:
            - type: string
            - type: 'null'
          description: External system identifier for sync
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          description: Parent project ID for nested projects
        order:
          type: integer
          minimum: 0
          description: Sort order for manual project ordering
        isArchived:
          type: boolean
          description: Whether the project is archived
        syncMode:
          type: string
          enum: [TAPTIDY, CALDAV, LOCAL]
        syncAccountId:
          oneOf:
            - type: string
            - type: 'null'
          description: External sync account identifier
        syncMetadata:
          $ref: '#/components/schemas/SyncMetadata'
        storeLayoutTemplate:
          oneOf:
            - type: string
              enum: [grocery, hardware, general]
            - type: 'null'
          description: Store layout template used by shopping lists.
        isActive:
          type: boolean
          default: false
          description: Whether a shopping trip is currently in progress

    ProjectResponse:
      type: object
      required: [id, name, createdAt, updatedAt, shareRole, shareStatus, isActive]
      properties:
        id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        name:
          type: string
          example: Work Tasks
        description:
          oneOf:
            - type: string
            - type: 'null'
          example: Tasks related to work projects
        color:
          oneOf:
            - type: string
              pattern: ^#[0-9A-Fa-f]{6}$
            - type: 'null'
          example: "#4A90E2"
        icon:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
          description: Lucide icon name (kebab-case) shown alongside the list color
          example: shopping-cart
        projectType:
          oneOf:
            - type: string
              enum: [standard, shopping, packing]
            - type: 'null'
          description: Canonical project classification field.
          example: shopping
        storeLayoutTemplate:
          oneOf:
            - type: string
              enum: [grocery, hardware, general]
            - type: 'null'
          description: Store layout template used by shopping lists.
          example: grocery
        isActive:
          type: boolean
          default: false
          description: Whether a shopping trip is currently in progress
        isInbox:
          type: boolean
          default: false
          description: Whether this is the user's default Inbox project (exactly one per user)
        remoteId:
          oneOf:
            - type: string
            - type: 'null'
          description: External system identifier for sync
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          description: Parent project ID for nested projects
        order:
          type: integer
          minimum: 0
          description: Sort order for manual project ordering
        isArchived:
          type: boolean
          description: Whether the project is archived
        syncMode:
          type: string
          enum: [TAPTIDY, CALDAV, LOCAL]
        syncAccountId:
          oneOf:
            - type: string
            - type: 'null'
          description: External sync account identifier
        syncMetadata:
          $ref: '#/components/schemas/SyncMetadata'
        createdAt:
          type: string
          format: date-time
          example: "2026-02-10T10:00:00Z"
        updatedAt:
          type: string
          format: date-time
          example: "2026-02-11T10:00:00Z"
        shareRole:
          oneOf:
            - type: string
              enum: [owner, editor, viewer]
            - type: 'null'
          description: Current user's role if project is shared (null for owned projects)
          example: editor
        shareStatus:
          oneOf:
            - type: string
              enum: [pending, accepted, declined, revoked, archived]
            - type: 'null'
          description: Share status if project is shared (null for owned projects)
          example: accepted

    # Template schemas
    TemplateCreate:
      type: object
      required: [name, defaultTitle]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          example: Weekly Review
        description:
          oneOf:
            - type: string
              maxLength: 1000
            - type: 'null'
        defaultTitle:
          type: string
          minLength: 1
          maxLength: 500
          example: Run weekly review
        defaultDescription:
          oneOf:
            - type: string
              maxLength: 10000
            - type: 'null'
        defaultProjectId:
          oneOf:
            - type: string
            - type: 'null'
          description: Optional project ID to prefill task creation
        defaultTags:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 50
          default: []
          description: Non-empty tag names; blank values are rejected
        defaultPriority:
          type: integer
          minimum: 0
          maximum: 4
          default: 0
          description: Canonical priority range (0..4)
        category:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
        templateType:
          oneOf:
            - type: string
              enum: [general, household]
            - type: 'null'
        householdTaskKind:
          oneOf:
            - type: string
              enum: [shared, errand]
            - type: 'null'
        defaultRequiresApproval:
          type: boolean
          default: false
        defaultRotation:
          type: boolean
          default: false
        defaultAssigneeStrategy:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
        defaultEffortWeight:
          description: >-
            #2226 chore economy. Point value copied onto every task this template
            generates. Null inherits the task default (1).
          oneOf:
            - type: integer
              minimum: 1
              maximum: 999
            - type: 'null'
        defaultTaskTier:
          description: >-
            #2226 chore economy. Copied onto every task this template generates.
            Null inherits the task default (baseline).
          oneOf:
            - type: string
              enum: [baseline, hustle]
            - type: 'null'
        items:
          type: array
          items:
            $ref: '#/components/schemas/TemplateItemInput'
          description: >-
            Checklist items. Omit to create a single-task template from
            defaultTitle (historical behavior); a non-empty array makes
            instantiate() create one task per item instead.

    TemplateUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        description:
          oneOf:
            - type: string
              maxLength: 1000
            - type: 'null'
        defaultTitle:
          type: string
          minLength: 1
          maxLength: 500
        defaultDescription:
          oneOf:
            - type: string
              maxLength: 10000
            - type: 'null'
        defaultProjectId:
          oneOf:
            - type: string
            - type: 'null'
        defaultTags:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 50
        defaultPriority:
          type: integer
          minimum: 0
          maximum: 4
        category:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
        templateType:
          oneOf:
            - type: string
              enum: [general, household]
            - type: 'null'
        householdTaskKind:
          oneOf:
            - type: string
              enum: [shared, errand]
            - type: 'null'
        defaultRequiresApproval:
          type: boolean
          default: false
        defaultRotation:
          type: boolean
          default: false
        defaultAssigneeStrategy:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
        defaultEffortWeight:
          description: >-
            #2226 chore economy. Point value copied onto every task this template
            generates. Null inherits the task default (1).
          oneOf:
            - type: integer
              minimum: 1
              maximum: 999
            - type: 'null'
        defaultTaskTier:
          description: >-
            #2226 chore economy. Copied onto every task this template generates.
            Null inherits the task default (baseline).
          oneOf:
            - type: string
              enum: [baseline, hustle]
            - type: 'null'
        items:
          type: array
          items:
            $ref: '#/components/schemas/TemplateItemInput'
          description: >-
            Replaces the full checklist wholesale. Omit to leave items
            untouched; an empty array clears them.

    TemplateResponse:
      type: object
      required:
        - id
        - userId
        - name
        - defaultTitle
        - defaultTags
        - defaultPriority
        - isSystem
        - items
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
        name:
          type: string
        description:
          oneOf:
            - type: string
            - type: 'null'
        defaultTitle:
          type: string
        defaultDescription:
          oneOf:
            - type: string
            - type: 'null'
        defaultProjectId:
          oneOf:
            - type: string
            - type: 'null'
        defaultTags:
          type: array
          items:
            type: string
        defaultPriority:
          type: integer
          minimum: 0
          maximum: 4
        category:
          oneOf:
            - type: string
            - type: 'null'
        templateType:
          oneOf:
            - type: string
              enum: [general, household]
            - type: 'null'
        householdTaskKind:
          oneOf:
            - type: string
              enum: [shared, errand]
            - type: 'null'
        defaultRequiresApproval:
          type: boolean
          default: false
        defaultRotation:
          type: boolean
          default: false
        defaultAssigneeStrategy:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
        defaultEffortWeight:
          description: >-
            #2226 chore economy. Point value copied onto every task this template
            generates. Null inherits the task default (1).
          oneOf:
            - type: integer
              minimum: 1
              maximum: 999
            - type: 'null'
        defaultTaskTier:
          description: >-
            #2226 chore economy. Copied onto every task this template generates.
            Null inherits the task default (baseline).
          oneOf:
            - type: string
              enum: [baseline, hustle]
            - type: 'null'
        items:
          type: array
          items:
            $ref: '#/components/schemas/TemplateItem'
        isSystem:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    TemplateItem:
      type: object
      required: [id, title, order]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        order:
          type: integer

    TemplateItemInput:
      type: object
      required: [title]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
        order:
          type: integer
          minimum: 0
          description: Defaults to array position when omitted

    TemplateListResponse:
      type: object
      required: [templates]
      properties:
        templates:
          type: array
          items:
            $ref: '#/components/schemas/TemplateResponse'

    TemplateBatchItem:
      allOf:
        - $ref: '#/components/schemas/TemplateCreate'
        - type: object
          properties:
            id:
              type: string
              description: >-
                Optional client-supplied id. Existing rows owned by the caller
                are updated in place; unknown ids are created with this id.
                System templates and rows owned by other users are skipped.

    TemplateBatchResponse:
      type: object
      required: [templates, created, updated, skipped]
      properties:
        templates:
          type: array
          items:
            $ref: '#/components/schemas/TemplateResponse'
          description: The caller's full visible template list after the upsert
        created:
          type: integer
        updated:
          type: integer
        skipped:
          type: integer
          description: Items ignored because they target system templates or other users' rows

    TemplateInstantiateRequest:
      type: object
      properties:
        overrides:
          type: object
          properties:
            title:
              type: string
              minLength: 1
              maxLength: 500
            description:
              oneOf:
                - type: string
                  maxLength: 10000
                - type: 'null'
            priority:
              type: integer
              minimum: 0
              maximum: 4
            projectId:
              oneOf:
                - type: string
                - type: 'null'
            tags:
              type: array
              items:
                type: string
                minLength: 1
                maxLength: 50

    TemplateInstantiateResponse:
      type: object
      required: [tasks]
      properties:
        tasks:
          type: array
          items:
            $ref: '#/components/schemas/TemplateInstantiateTask'
          description: >-
            One task per checklist item; a single-element array for
            templates with no items (the historical single-task behavior).

    TemplateInstantiateTask:
      type: object
      required: [id, title, completed, priority, tags, createdAt, updatedAt]
      properties:
        id:
          type: string
        title:
          type: string
        description:
          oneOf:
            - type: string
            - type: 'null'
        completed:
          type: boolean
        priority:
          type: integer
          minimum: 0
          maximum: 4
        tags:
          type: array
          items:
            type: string
        projectId:
          oneOf:
            - type: string
            - type: 'null'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    # FCM schemas
    FcmRegisterRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: Firebase Cloud Messaging device token
          example: "fcm-token-abc123"
        platform:
          type: string
          enum: [android, ios, web]
          default: android
          description: Device platform
          example: android

    FcmRegisterResponse:
      type: object
      required: [success, deviceId]
      properties:
        success:
          type: boolean
          example: true
        deviceId:
          type: string
          format: uuid
          description: Internal device record ID
          example: "550e8400-e29b-41d4-a716-446655440000"

    FcmUnregisterRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: Firebase Cloud Messaging device token to remove
          example: "fcm-token-abc123"

    FcmDevice:
      type: object
      required: [id, platform, lastUsedAt, createdAt]
      properties:
        id:
          type: string
          format: uuid
          description: Internal device record ID
          example: "550e8400-e29b-41d4-a716-446655440000"
        platform:
          type: string
          enum: [android, ios, web]
          example: android
        lastUsedAt:
          type: string
          format: date-time
          example: "2026-02-17T10:00:00Z"
        createdAt:
          type: string
          format: date-time
          example: "2026-02-10T10:00:00Z"

    FcmDeviceListResponse:
      type: object
      required: [devices]
      properties:
        devices:
          type: array
          items:
            $ref: '#/components/schemas/FcmDevice'

    # ── UnifiedPush Schemas ─────────────────────────────────────────────────

    UnifiedPushRegisterRequest:
      type: object
      required: [endpoint]
      properties:
        endpoint:
          type: string
          format: uri
          description: UnifiedPush distributor endpoint URL
          example: "https://ntfy.sh/up-abc123"
        platform:
          type: string
          enum: [android, ios]
          default: android
          description: Device platform
          example: android

    UnifiedPushRegisterResponse:
      type: object
      required: [success, id]
      properties:
        success:
          type: boolean
          example: true
        id:
          type: string
          format: uuid
          description: Internal endpoint record ID
          example: "550e8400-e29b-41d4-a716-446655440000"

    UnifiedPushUnregisterRequest:
      type: object
      required: [endpoint]
      properties:
        endpoint:
          type: string
          format: uri
          description: UnifiedPush endpoint URL to remove
          example: "https://ntfy.sh/up-abc123"

    UnifiedPushDevice:
      type: object
      required: [id, platform, lastUsedAt, createdAt]
      properties:
        id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        platform:
          type: string
          enum: [android, ios]
          example: android
        lastUsedAt:
          type: string
          format: date-time
          example: "2026-03-17T10:00:00Z"
        createdAt:
          type: string
          format: date-time
          example: "2026-03-10T10:00:00Z"

    UnifiedPushDeviceListResponse:
      type: object
      required: [devices]
      properties:
        devices:
          type: array
          items:
            $ref: '#/components/schemas/UnifiedPushDevice'

    # -----------------------------------------------------------------------
    # QOL-21: Collaboration Presence + Edit Collision Hints
    # -----------------------------------------------------------------------
    PresenceInfo:
      type: object
      description: Real-time presence information for collaborative editing
      properties:
        taskId:
          type: string
          format: uuid
          description: Task being viewed or edited
        projectId:
          type: string
          format: uuid
          description: Project being viewed
        activeUsers:
          type: array
          items:
            $ref: '#/components/schemas/PresenceUser'
          description: List of users currently active on this entity
        editLock:
          $ref: '#/components/schemas/EditLockInfo'
          description: Current edit lock information (if any)

    PresenceUser:
      type: object
      required: [userId, name]
      properties:
        userId:
          type: string
          format: uuid
        name:
          type: string
          description: User display name
        email:
          type: string
          format: email
        lastActiveAt:
          type: string
          format: date-time
          description: Last activity timestamp

    EditLockInfo:
      type: object
      description: Edit lock metadata for optimistic collision detection
      properties:
        lockedBy:
          type: string
          format: uuid
          description: User ID currently editing
        lockedByName:
          type: string
          description: Display name of editing user
        lockedAt:
          type: string
          format: date-time
          description: When the lock was acquired
        lockVersion:
          type: integer
          description: Version counter for optimistic locking

    # Provider schemas
    ProviderType:
      type: string
      enum: [todoist, caldav]
      description: Supported external provider types

    ProviderStatus:
      type: string
      enum: [healthy, degraded, unhealthy, unimplemented]
      description: Provider health status

    ProviderTodoistConfig:
      type: object
      required: [apiToken]
      properties:
        apiToken:
          type: string
          description: Todoist API token
          example: "0123456789abcdef0123456789abcdef01234567"
        timezone:
          type: string
          description: Client-resolved IANA timezone, used to resolve the account default when unset.

    ProviderCalDAVConfig:
      type: object
      required: [url, username, password]
      properties:
        url:
          type: string
          format: uri
          description: CalDAV server URL
          example: "https://caldav.example.com/dav/"
        username:
          type: string
          example: "user@example.com"
        password:
          type: string
          format: password
          example: "s3cr3t"

    ProviderCreateRequest:
      oneOf:
        - $ref: '#/components/schemas/ProviderCreateRequestTodoist'
        - $ref: '#/components/schemas/ProviderCreateRequestCalDAV'
      discriminator:
        propertyName: type
        mapping:
          todoist: '#/components/schemas/ProviderCreateRequestTodoist'
          caldav: '#/components/schemas/ProviderCreateRequestCalDAV'
      description: Request payload to create a provider with type-specific configuration.

    ProviderCreateRequestTodoist:
      type: object
      required: [type, config]
      properties:
        type:
          type: string
          enum: [todoist]
          description: Must be "todoist" for Todoist providers.
        config:
          $ref: '#/components/schemas/ProviderTodoistConfig'
          description: Provider-specific configuration for a Todoist provider.

    ProviderCreateRequestCalDAV:
      type: object
      required: [type, config]
      properties:
        type:
          type: string
          enum: [caldav]
          description: Must be "caldav" for CalDAV providers.
        config:
          $ref: '#/components/schemas/ProviderCalDAVConfig'
          description: Provider-specific configuration for a CalDAV provider.

    ProviderResponse:
      type: object
      required: [id, type, isActive, status, createdAt, updatedAt, deadLetterCount]
      properties:
        id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        type:
          $ref: '#/components/schemas/ProviderType'
        isActive:
          type: boolean
          description: Whether the provider is currently active for sync
          example: false
        status:
          $ref: '#/components/schemas/ProviderStatus'
        lastCheckedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          example: "2026-02-17T10:00:00Z"
        error:
          oneOf:
            - type: string
            - type: 'null'
          description: Last validation/sync error message
        nextAction:
          oneOf:
            - type: string
            - type: 'null'
          description: Informational message about pending implementation status
        createdAt:
          type: string
          format: date-time
          example: "2026-02-10T10:00:00Z"
        updatedAt:
          type: string
          format: date-time
          example: "2026-02-17T10:00:00Z"
        deadLetterCount:
          type: integer
          minimum: 0
          description: Outbox operations for this provider stuck in 'failed'/'dead' status. Independent of `status` (connection health) — a per-task push failure doesn't affect the connection itself. Shared with SyncStatusResponse.providers[x].deadLetterCount.
          example: 0

    ProviderListResponse:
      type: object
      required: [providers]
      properties:
        providers:
          type: array
          items:
            $ref: '#/components/schemas/ProviderResponse'

    ProviderStatusResponse:
      allOf:
        - $ref: '#/components/schemas/ProviderResponse'
        - type: object
          properties:
            lastSuccessfulSync:
              oneOf:
                - type: string
                  format: date-time
                - type: 'null'
              example: "2026-02-16T08:00:00Z"
            consecutiveFailures:
              type: integer
              minimum: 0
              example: 0
            tokenValidityHint:
              type: string
              description: Human-readable hint about the token validity state (e.g. "Validated today", "Not yet validated").
              example: "Validated today"
            effectiveTimezone:
              type: string
              description: The timezone currently in effect for this provider.
              example: "America/New_York"
            healthSummary:
              type: string
              description: Human-readable summary of the current provider health state.
              example: "Provider is operating normally."
            suspectedWipe:
              type: boolean
              description: Whether a remote provider data wipe has been suspected.
              example: false
            firstFailureAt:
              oneOf:
                - type: string
                  format: date-time
                - type: 'null'
              description: Timestamp of the first consecutive failure in the current failure streak.
            lastPulledAt:
              oneOf:
                - type: string
                  format: date-time
                - type: 'null'
              description: Timestamp of the last successful pull from the remote provider.
            lastPushedAt:
              oneOf:
                - type: string
                  format: date-time
                - type: 'null'
              description: Timestamp of the last successful push to the remote provider.

    ProviderCheckResponse:
      type: object
      required: [success, message, checks]
      properties:
        success:
          type: boolean
          description: Whether all checks passed (no failures).
          example: true
        message:
          type: string
          description: High-level summary of the check result.
          example: "All checks passed."
        checks:
          type: array
          items:
            type: object
            required: [name, status, message]
            properties:
              name:
                type: string
                example: "token_validity"
              status:
                type: string
                enum: [pass, fail, warn]
                example: "pass"
              message:
                type: string
                example: "API token is valid and accepted by Todoist."
        details:
          type: object
          properties:
            tokenValid:
              type: boolean
              description: Whether the stored credentials were accepted by the remote provider.
            reachable:
              type: boolean
              description: Whether the remote provider API was reachable.
            taskCount:
              type: integer
              description: Number of tasks visible via the provider (read-only, Todoist only).
            suspectedDrift:
              type: boolean
              description: Whether calendar/provider drift or a suspected wipe was detected.
            driftHint:
              oneOf:
                - type: string
                - type: 'null'
              description: Actionable hint when drift is detected.

    ProviderValidationResponse:
      type: object
      required: [success, message]
      properties:
        success:
          type: boolean
          example: true
        message:
          type: string
          example: "Provider validated successfully"
        error:
          oneOf:
            - type: string
            - type: 'null'
        nextAction:
          oneOf:
            - type: string
            - type: 'null'

    ProviderUpdateRequest:
      type: object
      properties:
        syncMode:
          type: string
          enum: [import, export, bidirectional, overlay-only, manual, selective, import-all]
        syncInterval:
          type: integer
          minimum: 1
          description: Sync interval in minutes
        projectSyncMode:
          type: string
          enum: [all, selected]
        selectedProjects:
          type: array
          items:
            type: string
        isActive:
          type: boolean
        timezone:
          type: string
          description: IANA timezone override, persisted into the provider's settings blob.

    TodoistValidateRequest:
      type: object
      required: [apiToken]
      properties:
        apiToken:
          type: string
          description: Todoist API token to validate
          example: "0123456789abcdef0123456789abcdef01234567"
        timezone:
          type: string
          description: Client-resolved IANA timezone, used to resolve the account default when unset.

    TodoistValidateResponse:
      type: object
      required: [valid, projects]
      properties:
        valid:
          type: boolean
          example: true
        projects:
          type: array
          items:
            $ref: '#/components/schemas/TodoistProject'

    TodoistProject:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
          example: "2203306141"
        name:
          type: string
          example: "Inbox"
        color:
          oneOf:
            - type: string
            - type: 'null'
          example: "charcoal"
        taskCount:
          type: integer
          example: 5

    TodoistProjectsResponse:
      type: object
      required: [projects]
      properties:
        projects:
          type: array
          items:
            $ref: '#/components/schemas/TodoistProject'

    TodoistOAuthCallbackRequest:
      type: object
      required: [code]
      properties:
        code:
          type: string
          description: OAuth authorization code from Todoist redirect
          example: "abc123oauth"

    ProviderSyncResponse:
      type: object
      required: [success, imported, exported, conflicts]
      properties:
        success:
          type: boolean
          example: true
        imported:
          type: integer
          example: 12
        exported:
          type: integer
          example: 3
        conflicts:
          type: integer
          example: 0

    ProviderFetchResponse:
      type: object
      required: [tasks]
      properties:
        tasks:
          type: array
          items:
            type: object
            description: Raw task data from external provider (shape varies by provider)

    # Tag schemas
    TagCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Non-empty tag name; blank values are rejected
          example: "work"
        color:
          oneOf:
            - type: string
              pattern: ^#[0-9A-Fa-f]{6}$
            - type: 'null'
          example: "#4A90E2"
        icon:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
          description: Lucide icon name (kebab-case) shown alongside the tag color
          example: paw
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          description: Parent tag ID for hierarchical tags
        order:
          type: integer
          minimum: 0
          description: Sort order for manual tag ordering
          example: 0

    TagUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        color:
          oneOf:
            - type: string
              pattern: ^#[0-9A-Fa-f]{6}$
            - type: 'null'
        icon:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
          description: Lucide icon name (kebab-case) shown alongside the tag color
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        order:
          type: integer
          minimum: 0

    TagResponse:
      type: object
      required: [id, name, order, createdAt, updatedAt, syncMetadata]
      properties:
        id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        name:
          type: string
          example: "work"
        color:
          oneOf:
            - type: string
            - type: 'null'
          example: "#4A90E2"
        icon:
          oneOf:
            - type: string
              maxLength: 100
            - type: 'null'
          description: Lucide icon name (kebab-case) shown alongside the tag color
          example: paw
        parentId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        order:
          type: integer
          example: 0
        createdAt:
          type: string
          format: date-time
          example: "2026-02-10T10:00:00Z"
        updatedAt:
          type: string
          format: date-time
          example: "2026-02-11T10:00:00Z"
        syncMetadata:
          $ref: '#/components/schemas/SyncMetadata'

    TagListResponse:
      type: object
      required: [tags]
      properties:
        tags:
          type: array
          items:
            $ref: '#/components/schemas/TagResponse'

    # Stats schemas
    WeeklyCompletionCount:
      type: object
      description: Task completion count for a single day
      required: [date, count]
      properties:
        date:
          type: string
          format: date
          example: "2026-02-10"
        count:
          type: integer
          minimum: 0
          example: 5

    WeeklyStatsResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/WeeklyCompletionCount'

    TimeDistributionBucket:
      type: object
      required: [hour, count]
      properties:
        hour:
          type: integer
          minimum: 0
          maximum: 23
          description: Hour of day (0–23)
          example: 9
        count:
          type: integer
          minimum: 0
          example: 3

    TimeDistributionResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TimeDistributionBucket'

    DailySummaryResponse:
      type: object
      required: [completed, total, completionRate]
      properties:
        completed:
          type: integer
          minimum: 0
          example: 7
        total:
          type: integer
          minimum: 0
          example: 10
        completionRate:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Ratio of completed/total (0.0–1.0)
          example: 0.7
        overdue:
          type: integer
          minimum: 0
          example: 2

    ConsistencyEntry:
      type: object
      required: [date, completed]
      properties:
        date:
          type: string
          format: date
          example: "2026-02-10"
        completed:
          type: integer
          minimum: 0
          example: 3

    RoutineConsistencyResponse:
      type: object
      required: [data, streak]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ConsistencyEntry'
        streak:
          type: integer
          minimum: 0
          description: Current consecutive days streak
          example: 5

    KPIsResponse:
      type: object
      required: [totalCompleted, completionRate, avgDailyCompleted, longestStreak]
      properties:
        totalCompleted:
          type: integer
          minimum: 0
          example: 42
        completionRate:
          type: number
          format: float
          minimum: 0
          maximum: 1
          example: 0.85
        avgDailyCompleted:
          type: number
          format: float
          minimum: 0
          example: 6.0
        longestStreak:
          type: integer
          minimum: 0
          example: 12

    WeeklyWin:
      type: object
      required: [taskId, title, completedAt]
      properties:
        taskId:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        title:
          type: string
          example: "Finish Q1 report"
        completedAt:
          type: string
          format: date-time
          example: "2026-02-14T16:30:00Z"

    WeeklyWinsResponse:
      type: object
      required: [wins]
      properties:
        wins:
          type: array
          items:
            $ref: '#/components/schemas/WeeklyWin'

    # -----------------------------------------------------------------------
    # Phase 3: Auth schemas
    # -----------------------------------------------------------------------
    UserResponse:
      type: object
      required: [id, email, role, isEmailVerified, createdAt]
      properties:
        id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        email:
          type: string
          format: email
          example: user@example.com
        name:
          oneOf:
            - type: string
            - type: 'null'
          example: Jane Doe
        role:
          type: string
          enum: [user, admin]
          example: user
        isEmailVerified:
          type: boolean
          example: true
        createdAt:
          type: string
          format: date-time
          example: "2026-01-01T00:00:00Z"

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          example: user@example.com
        password:
          type: string
          format: password

    LoginResponse:
      type: object
      required: [user, expiresAt]
      properties:
        user:
          $ref: '#/components/schemas/UserResponse'
        sessionToken:
          oneOf:
            - type: string
            - type: 'null'
          description: Deprecated — session is now managed via HTTP-only cookie. Always null.
        expiresAt:
          type: string
          format: date-time

    OkResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean

    PasswordlessLoginResponse:
      type: object
      description: >-
        Returned by OAuth, OTP and magic-link sign-in. Sets the session cookie
        and also returns the raw sessionToken so native clients can send it as a
        Bearer token.
      required: [user, sessionToken, expiresAt]
      properties:
        user:
          type: object
          required: [id, email]
          properties:
            id:
              type: string
            email:
              type: string
              format: email
            name:
              oneOf:
                - type: string
                - type: 'null'
            isEmailVerified:
              type: boolean
        sessionToken:
          type: string
        expiresAt:
          type: string
          format: date-time

    WebauthnOptionsResponse:
      type: object
      required: [flowId, options]
      description: WebAuthn ceremony options plus an opaque flowId to echo on verify.
      properties:
        flowId:
          type: string
        options:
          type: object
          additionalProperties: true
          description: PublicKeyCredentialCreationOptions or PublicKeyCredentialRequestOptions (JSON form).

    WebauthnRegisterVerifyRequest:
      type: object
      required: [flowId, response]
      properties:
        flowId:
          type: string
        name:
          type: string
          description: Optional user-facing label for the passkey.
        response:
          type: object
          additionalProperties: true
          description: RegistrationResponseJSON from the authenticator.

    WebauthnLoginOptionsRequest:
      type: object
      properties:
        email:
          type: string
          format: email

    WebauthnLoginVerifyRequest:
      type: object
      required: [flowId, response]
      properties:
        flowId:
          type: string
        response:
          type: object
          additionalProperties: true
          description: AuthenticationResponseJSON from the authenticator.

    WebauthnCredential:
      type: object
      required: [id, backedUp, createdAt]
      description: A stored passkey's public metadata. Never includes the public key or signature counter.
      properties:
        id:
          type: string
        name:
          type: [string, 'null']
          description: User-facing label (device model by default).
        deviceType:
          type: [string, 'null']
          enum: [singleDevice, multiDevice, null]
        backedUp:
          type: boolean
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type: [string, 'null']
          format: date-time

    WebauthnCredentialsResponse:
      type: object
      required: [credentials]
      properties:
        credentials:
          type: array
          items:
            $ref: '#/components/schemas/WebauthnCredential'

    WebauthnCredentialRenameRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100

    RegisterRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          example: newuser@example.com
        password:
          type: string
          minLength: 8
        name:
          type: string
          example: Jane Doe

    VerifyEmailRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string

    ForgotPasswordRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email

    ResetPasswordRequest:
      type: object
      required: [token, password]
      properties:
        token:
          type: string
        password:
          type: string
          minLength: 8

    ChangePasswordRequest:
      type: object
      required: [currentPassword, newPassword]
      properties:
        currentPassword:
          type: string
        newPassword:
          type: string
          minLength: 8

    VerifyPasswordRequest:
      type: object
      required: [password]
      properties:
        password:
          type: string

    ChangeEmailRequest:
      type: object
      required: [newEmail, password]
      properties:
        newEmail:
          type: string
          format: email
        password:
          type: string

    VerifyEmailChangeRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string

    ChangeNameRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100

    RefreshResponse:
      type: object
      required: [message, expiresAt, refreshed]
      properties:
        message:
          type: string
        expiresAt:
          type: string
          format: date-time
        refreshed:
          type: boolean
          description: True when session was refreshed (triggered when <7 days remaining)

    CsrfTokenResponse:
      type: object
      required: [csrfToken]
      properties:
        csrfToken:
          type: string
          description: >
            CSRF token to include as the X-CSRF-Token header on all mutating
            requests (POST, PATCH, DELETE). The token is also set in a csrf_token
            cookie (not httpOnly) so browser clients can read it and apply the
            double-submit cookie pattern.

    WhoamiResponse:
      type: object
      required: [userId, authMode, timestamp]
      description: >
        Response returned by the development-only GET /api/debug/whoami endpoint.
        Only available when TAPTIDY_DEV_AUTH=1.
      properties:
        userId:
          type: string
        authMode:
          type: string
          description: "Auth strategy resolved by the server (e.g. 'bearer', 'dev')"
        workspaceId:
          oneOf:
            - type: string
            - type: 'null'
        timestamp:
          type: string
          format: date-time
        requestId:
          oneOf:
            - type: string
            - type: 'null'

    # -----------------------------------------------------------------------
    # Phase 3: Routines schemas
    # -----------------------------------------------------------------------
    RoutineResponse:
      type: object
      required: [id, title, targetMinutes]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
          example: Morning focus
        targetMinutes:
          type: integer
          minimum: 1
          example: 25
        difficultyMode:
          oneOf:
            - type: string
              enum: [easy, normal, hard]
            - type: 'null'
        totalMinutes:
          type: integer
          example: 150
        streak:
          type: integer
          example: 5
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    RoutineCreate:
      type: object
      required: [title, targetMinutes]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
          example: Morning focus
        targetMinutes:
          type: integer
          minimum: 1
          example: 25
        difficultyMode:
          type: string
          enum: [easy, normal, hard]

    RoutineUpdate:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        targetMinutes:
          type: integer
          minimum: 1

    RoutineLogRequest:
      type: object
      required: [minutes]
      properties:
        minutes:
          type: integer
          minimum: 1
          example: 25

    RoutineReorderRequest:
      type: object
      required: [orderedIds]
      properties:
        orderedIds:
          type: array
          items:
            type: string
            format: uuid

    RoutineTemplateV2:
      type: object
      required: [id, userId, title, scheduleType, routineStyle, targetMinutes, difficultyMode, streakCurrent, streakBest, isActive]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        title:
          type: string
        description:
          oneOf:
            - type: string
            - type: 'null'
        scheduleType:
          type: string
          enum: [fixed, dynamic, flexible]
        recurrenceRule:
          oneOf:
            - type: string
            - type: 'null'
        dynamicIntervalHours:
          oneOf:
            - type: integer
            - type: 'null'
        frequencyGoalCount:
          oneOf:
            - type: integer
              minimum: 1
              maximum: 31
            - type: 'null'
        frequencyGoalPeriod:
          oneOf:
            - type: string
              enum: [week, month]
            - type: 'null'
        routineStyle:
          type: string
          enum: [log-only, task-generating]
        targetMinutes:
          type: integer
        difficultyMode:
          type: string
          enum: [gentle, balanced, intense]
        taskTemplateId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        streakCurrent:
          type: integer
        streakBest:
          type: integer
        isActive:
          type: boolean
        legacyRoutineId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        householdId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        assigneeMemberId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        rotationStrategy:
          oneOf:
            - type: string
              enum: [round_robin, fixed]
            - type: 'null'
        reminderEnabled:
          type: boolean
          default: false
        reminderTime:
          oneOf:
            - type: string
              pattern: '^([01]\\d|2[0-3]):([0-5]\\d)$'
            - type: 'null'
        reminderOffsetMinutes:
          oneOf:
            - type: integer
              minimum: 0
              maximum: 1440
            - type: 'null'
        reminderDaysOfWeek:
          type: array
          description: Weekdays on which to fire the reminder, using 0=Monday through 6=Sunday. Empty means all days.
          maxItems: 7
          items:
            type: integer
            minimum: 0
            maximum: 6
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    RoutineTemplateV2Create:
      type: object
      description: >
        When routineStyle is 'task-generating', a backing task template is required:
        supply either taskTemplateId (an existing template's UUID) or the inline
        taskTemplate object (#2226), never both. The server returns 400 if
        task-generating is requested with neither.
      required: [title, scheduleType, targetMinutes]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        description:
          type: string
          maxLength: 1000
        scheduleType:
          type: string
          enum: [fixed, dynamic, flexible]
        recurrenceRule:
          type: string
        dynamicIntervalHours:
          type: integer
          minimum: 1
          maximum: 168
        frequencyGoalCount:
          type: integer
          minimum: 1
          maximum: 31
        frequencyGoalPeriod:
          type: string
          enum: [week, month]
        routineStyle:
          type: string
          enum: [log-only, task-generating]
          description: If set to 'task-generating', either taskTemplateId or the inline taskTemplate must also be provided.
        taskTemplateId:
          description: Required when routineStyle is 'task-generating' unless taskTemplate is supplied. Must reference a valid task template accessible by the user.
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        taskTemplate:
          description: >-
            #2226. Creates the backing task template in the same request, as an
            alternative to taskTemplateId. Household chores use this because they
            must be task-generating for points and approval to work. Supply one or
            the other, never both.
          type: object
          additionalProperties: false
          properties:
            requiresApproval:
              type: boolean
            effortWeight:
              oneOf:
                - type: integer
                  minimum: 1
                  maximum: 999
                - type: 'null'
            taskTier:
              oneOf:
                - type: string
                  enum: [baseline, hustle]
                - type: 'null'
        householdId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        assigneeMemberId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        rotationStrategy:
          oneOf:
            - type: string
              enum: [round_robin, fixed]
            - type: 'null'
        targetMinutes:
          type: integer
          minimum: 1
          maximum: 480
        difficultyMode:
          type: string
          enum: [gentle, balanced, intense]
        reminderEnabled:
          type: boolean
        reminderTime:
          type: string
          pattern: '^([01]\\d|2[0-3]):([0-5]\\d)$'
        reminderOffsetMinutes:
          type: integer
          minimum: 0
          maximum: 1440
        reminderDaysOfWeek:
          type: array
          description: Weekdays on which to fire the reminder, using 0=Monday through 6=Sunday. Empty means all days.
          maxItems: 7
          uniqueItems: true
          items:
            type: integer
            minimum: 0
            maximum: 6

    RoutineTemplateV2Update:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        description:
          type: string
          maxLength: 1000
        targetMinutes:
          type: integer
          minimum: 1
          maximum: 480
        difficultyMode:
          type: string
          enum: [gentle, balanced, intense]
        scheduleType:
          type: string
          enum: [fixed, dynamic, flexible]
        recurrenceRule:
          type: string
        dynamicIntervalHours:
          type: integer
          minimum: 1
          maximum: 168
        frequencyGoalCount:
          type: integer
          minimum: 1
          maximum: 31
        frequencyGoalPeriod:
          type: string
          enum: [week, month]
        routineStyle:
          type: string
          enum: [log-only, task-generating]
          description: If set to 'task-generating', taskTemplateId must also be provided.
        taskTemplateId:
          description: Required when routineStyle is 'task-generating'. Must reference a valid task template accessible by the user.
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        householdId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        assigneeMemberId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        rotationStrategy:
          oneOf:
            - type: string
              enum: [round_robin, fixed]
            - type: 'null'
        isActive:
          type: boolean
        reminderEnabled:
          type: boolean
        reminderTime:
          type: string
          pattern: '^([01]\\d|2[0-3]):([0-5]\\d)$'
        reminderOffsetMinutes:
          type: integer
          minimum: 0
          maximum: 1440
        reminderDaysOfWeek:
          type: array
          description: Weekdays on which to fire the reminder, using 0=Monday through 6=Sunday. Empty means all days.
          maxItems: 7
          uniqueItems: true
          items:
            type: integer
            minimum: 0
            maximum: 6

    RoutinePreviewRequest:
      type: object
      required: [recurrenceRule, startDate]
      properties:
        recurrenceRule:
          type: string
        startDate:
          type: string
          format: date-time
        endDate:
          type: string
          format: date-time
        maxOccurrences:
          type: integer
          minimum: 1
          maximum: 365

    RoutinePreviewResponse:
      type: object
      required: [recurrenceRule, startDate, endDate, total, occurrences]
      properties:
        recurrenceRule:
          type: string
        startDate:
          type: string
          format: date-time
        endDate:
          type: string
          format: date-time
        total:
          type: integer
        occurrences:
          type: array
          items:
            type: object
            required: [date, recurrenceId, isException]
            properties:
              date:
                type: string
                format: date-time
              recurrenceId:
                type: string
              isException:
                type: boolean

    RoutineOccurrenceCompleteRequest:
      type: object
      required: [minutesLogged]
      properties:
        minutesLogged:
          type: integer
          minimum: 0
          maximum: 480
        notes:
          type: string
          maxLength: 1000

    RoutineOccurrenceSkipRequest:
      type: object
      properties:
        reason:
          type: string
          maxLength: 500

    RoutineOccurrenceSnoozeRequest:
      type: object
      required: [until]
      properties:
        until:
          type: string
          format: date-time

    RoutineFlexibleLogRequest:
      type: object
      required: [minutesLogged]
      properties:
        minutesLogged:
          type: integer
          minimum: 0
          maximum: 480
        notes:
          type: string
          maxLength: 1000

    FrequencyGoalProgress:
      type: object
      required: [completed, goal, periodEnd]
      properties:
        completed:
          type: integer
        goal:
          type: integer
        periodEnd:
          type: string
          format: date-time

    RoutineV2StatsResponse:
      type: object
      required: [period, summary]
      properties:
        period:
          type: object
          required: [start, end]
          properties:
            start:
              type: string
              format: date-time
            end:
              type: string
              format: date-time
        summary:
          type: object
          additionalProperties: true
        templates:
          type: array
          items:
            type: object
            additionalProperties: true

    RoutineV2TimelineResponse:
      type: object
      required: [period, events, byDate, pagination]
      properties:
        period:
          type: object
          required: [start, end]
          properties:
            start:
              type: string
              format: date-time
            end:
              type: string
              format: date-time
        events:
          type: array
          items:
            type: object
            additionalProperties: true
        byDate:
          type: object
          additionalProperties:
            type: array
            items:
              type: object
              additionalProperties: true
        pagination:
          type: object
          required: [hasMore, limit]
          properties:
            hasMore:
              type: boolean
            nextCursor:
              oneOf:
                - type: string
                - type: 'null'
            limit:
              type: integer

    RoutineCalendarResponse:
      type: object
      required: [timezone, days]
      properties:
        timezone:
          type: string
          description: IANA timezone used to bucket days.
          example: America/New_York
        days:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/RoutineCalendarDaySummary'

    RoutineCalendarDaySummary:
      type: object
      required:
        [dayKey, scheduledCount, completedCount, skippedCount, missedCount,
         pendingCount, snoozedCount, extras, entries, fraction, state, isFuture]
      properties:
        dayKey:
          type: string
          format: date
        scheduledCount:
          type: integer
        completedCount:
          type: integer
        skippedCount:
          type: integer
        missedCount:
          type: integer
        pendingCount:
          type: integer
        snoozedCount:
          type: integer
        extras:
          type: array
          items:
            $ref: '#/components/schemas/RoutineCalendarDayEntry'
        entries:
          type: array
          items:
            $ref: '#/components/schemas/RoutineCalendarDayEntry'
        fraction:
          type: number
        state:
          type: string
          enum: [none, scheduled, empty, partial, full]
        isFuture:
          type: boolean

    RoutineCalendarDayEntry:
      type: object
      required: [id, templateId, templateTitle, status, isFlexible]
      properties:
        id:
          type: string
        templateId:
          type: string
        templateTitle:
          type: string
        status:
          type: string
          enum: [pending, completed, skipped, snoozed, missed]
        minutesLogged:
          type: [integer, 'null']
        notes:
          type: [string, 'null']
        snoozeUntil:
          type: [string, 'null']
          format: date-time
        targetMinutes:
          type: [integer, 'null']
        idempotencyKey:
          type: [string, 'null']
        isFlexible:
          type: boolean

    # -----------------------------------------------------------------------
    # Phase 3: Views schemas
    # -----------------------------------------------------------------------
    CustomView:
      type: object
      required: [id, name, viewType, order, isSystem]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: Work tasks
        viewType:
          type: string
          enum: [list, kanban, calendar]
          example: kanban
        filterCriteria:
          oneOf:
            - type: object
            - type: 'null'
        grouping:
          oneOf:
            - type: string
            - type: 'null'
        icon:
          oneOf:
            - type: string
            - type: 'null'
          example: briefcase
        order:
          type: integer
        isSystem:
          type: boolean
          description: System views cannot be modified or deleted
        source:
          type: string
          enum: [user, smart]
          default: user
          description: Unified saved-query source — 'smart' rows are SmartListService-owned system views served via /api/v1/filters
        smartKey:
          oneOf:
            - type: string
            - type: 'null'
          description: Smart-list definition key for source='smart' rows
        conditions:
          oneOf:
            - type: object
            - type: 'null'
          description: Legacy saved-filter rules JSON (backfilled from taptidy_filters)
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    ViewCreate:
      type: object
      required: [name, viewType]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          example: Work tasks
        viewType:
          type: string
          enum: [list, kanban, calendar]
        filterCriteria:
          type: object
        grouping:
          type: string
        icon:
          type: string

    # -----------------------------------------------------------------------
    # Phase 3: Conflicts schemas
    # -----------------------------------------------------------------------
    SyncConflict:
      type: object
      required: [id, entityType, entityId, detectedAt]
      properties:
        id:
          type: string
          format: uuid
        entityType:
          type: string
          enum: [task, project, tag]
        entityId:
          type: string
          format: uuid
        localData:
          type: object
          description: Local version of the conflicting entity
        providerData:
          type: object
          description: Remote provider version of the entity
        providerId:
          type: string
        detectedAt:
          type: string
          format: date-time

    ResolvedConflict:
      type: object
      required: [id, entityType, entityId, status, detectedAt]
      properties:
        id:
          type: string
        entityType:
          type: string
          enum: [task, project, tag]
        entityId:
          type: string
        providerId:
          type: string
        status:
          type: string
          enum: [resolved, dismissed]
        resolution:
          type: string
        resolutionType:
          type: string
        reason:
          type: string
        resolvedAt:
          type: string
          format: date-time
        detectedAt:
          type: string
          format: date-time
        canUndo:
          type: boolean

    ConflictResolveRequest:
      type: object
      required: [resolution]
      properties:
        resolution:
          type: string
          enum: [local, remote, manual]
          example: local
        resolvedData:
          type: object
          description: Required when resolution is 'manual'

    ConflictResolveResponse:
      type: object
      required: [message, resolution, entityId]
      properties:
        message:
          type: string
        resolution:
          type: string
          enum: [local, remote, manual]
        entityId:
          type: string
          format: uuid

    ConflictBulkResolveRequest:
      type: object
      additionalProperties: false
      required: [resolution]
      properties:
        resolution:
          type: string
          enum: [local, remote]
          example: remote
        entityType:
          type: string
          enum: [task, project, tag]
          description: Limit bulk resolution to one entity type
        conflictIds:
          type: array
          minItems: 1
          maxItems: 100
          uniqueItems: true
          items:
            type: string
            format: uuid
          description: Limit bulk resolution to specific conflicts (max 100)

    ConflictBulkResolveFailure:
      type: object
      required: [conflictId, reason]
      properties:
        conflictId:
          type: string
          format: uuid
        reason:
          type: string
          enum: [DUPLICATE_NAME, INTERNAL_ERROR]

    ConflictBulkResolveResponse:
      type: object
      required: [resolved, skipped, failed, failures]
      properties:
        resolved:
          type: integer
        skipped:
          type: integer
          description: Conflicts dismissed because the local entity was deleted
        failed:
          type: integer
        failures:
          type: array
          items:
            $ref: '#/components/schemas/ConflictBulkResolveFailure'

    # -----------------------------------------------------------------------
    # PR3: Capture confidence + review engine schemas
    # -----------------------------------------------------------------------
    CaptureClassification:
      type: string
      enum: [TASK, INBOX, INFO, IGNORE]
      description: |
        Classifier decision for a capture:
        - TASK: actionable, routed to task creation if confidence >= threshold
        - INBOX: uncertain or requires review
        - INFO: informational; reviewable, may be auto-archived in future
        - IGNORE: spam/noise; excluded from review lists

    CaptureItem:
      type: object
      required: [id, userId, source, content, status, classification, createdAt, ageDays, isEligibleForReview]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
        source:
          type: string
          enum: [EMAIL, UI, API]
        content:
          type: string
        status:
          type: string
          enum: [INBOX, CONVERTED, ARCHIVED, RAW_INBOX, ACTIVE, SCHEDULED, SNOOZED, COMPLETED, DELETED]
          description: >
            Legacy status field. Historically only INBOX/CONVERTED/ARCHIVED were used.
            Newer records may carry RAW_INBOX, ACTIVE, SCHEDULED, SNOOZED, COMPLETED, or DELETED
            as the system migrated to the full lifecycle model. For new integrations prefer
            lifecycleState.
        classification:
          $ref: '#/components/schemas/CaptureClassification'
        confidence:
          oneOf:
            - type: number
              format: float
              minimum: 0
              maximum: 1
            - type: 'null'
          description: Classifier confidence score (0.0–1.0, 3 decimal precision)
        reasons:
          oneOf:
            - type: array
              items:
                type: string
            - type: 'null'
          description: Short reason codes from classifier (debug/telemetry)
        sourceRefId:
          oneOf:
            - type: string
            - type: 'null'
          description: ID of the originating inbound email (if source=EMAIL)
        convertedTaskId:
          oneOf:
            - type: string
            - type: 'null'
          description: Task ID if this capture was converted
        archivedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        reviewAfter:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Deferred review timestamp; null means not deferred
        createdAt:
          type: string
          format: date-time
        ageDays:
          type: integer
          description: Age of capture in whole days
        isEligibleForReview:
          type: boolean
          description: Server-computed eligibility based on age, reviewAfter, and classification rules
        lifecycleState:
          $ref: '#/components/schemas/LifecycleState'
          description: New lifecycle model state. Populated for captures created/migrated after the unified inbox model was introduced.
        itemType:
          $ref: '#/components/schemas/ItemType'
          description: Semantic type of the item after triage. Populated when lifecycleState is set.
        title:
          oneOf:
            - type: string
            - type: 'null'
          description: User-set or auto-derived title (first non-empty line of content). Populated when lifecycleState is set.
        notes:
          oneOf:
            - type: string
            - type: 'null'
          description: Additional notes beyond the primary content field.
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: When the item will resurface after a SNOOZE transition.
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Due date assigned when the item was promoted to a task.

    CaptureSummary:
      type: object
      required: [totalInbox, eligibleNow, breakdown]
      properties:
        totalInbox:
          type: integer
          description: Non-archived, non-ignored captures in INBOX or INFO status
        eligibleNow:
          type: integer
          description: Captures eligible for review right now
        oldestAgeDays:
          oneOf:
            - type: integer
            - type: 'null'
          description: Age in days of the oldest eligible capture (null if none)
        breakdown:
          type: object
          required: [TASK, INBOX, INFO, IGNORE]
          properties:
            TASK:
              type: integer
            INBOX:
              type: integer
            INFO:
              type: integer
            IGNORE:
              type: integer

    CaptureListResult:
      type: object
      required: [items, total, page, pageSize, hasMore]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/CaptureItem'
        total:
          type: integer
        page:
          type: integer
        pageSize:
          type: integer
        hasMore:
          type: boolean

    CaptureDeferRequest:
      type: object
      properties:
        days:
          type: integer
          minimum: 1
          maximum: 365
          description: Days to defer review. Defaults to user setting captureReviewSnoozeDaysDefault (7).

    CaptureActionResponse:
      type: object
      required: [success]
      properties:
        success:
          type: boolean
        taskId:
          type: string
          description: Present only on convert-to-task response
        deferredUntil:
          type: string
          format: date-time
          description: Present only on defer response

    BatchConvertRequest:
      type: object
      required: [captureIds]
      properties:
        captureIds:
          type: array
          items:
            type: string
            format: uuid

    BatchConvertResponse:
      type: object
      required: [batchId, createdTaskIds, convertedCaptureIds]
      properties:
        batchId:
          type: string
          format: uuid
        createdTaskIds:
          type: array
          items:
            type: string
            format: uuid
        convertedCaptureIds:
          type: array
          items:
            type: string
            format: uuid

    BatchConvertUndoRequest:
      type: object
      required: [batchId]
      properties:
        batchId:
          type: string
          format: uuid

    BatchConvertUndoResponse:
      type: object
      required: [success, restoredCaptureIds, removedTaskIds]
      properties:
        success:
          type: boolean
        restoredCaptureIds:
          type: array
          items:
            type: string
            format: uuid
        removedTaskIds:
          type: array
          items:
            type: string
            format: uuid

    CaptureComposeOverride:
      type: string
      enum: [AUTO, FORCE_TASK, FORCE_INBOX]

    CaptureComposeSource:
      type: string
      enum: [WEB, ANDROID, API]

    CaptureComposeContext:
      type: object
      properties:
        projectId:
          type: string
          format: uuid
        labels:
          type: array
          items:
            type: string
        defaultDueDate:
          type: string
          pattern: '^\d{4}-\d{2}-\d{2}$'
          description: Client-local all-day default (YYYY-MM-DD), applied only when the text parses no date.

    CapabilitiesResponse:
      type: object
      required: [webPush, todoist, googleCalendar, googleBackup]
      properties:
        webPush:
          type: boolean
          description: VAPID keys are configured — web push notifications can be delivered.
        todoist:
          type: boolean
          description: Todoist OAuth client credentials are configured.
        googleCalendar:
          type: boolean
          description: Google OAuth client credentials are configured for Calendar sync.
        googleBackup:
          type: boolean
          description: Google OAuth client credentials are configured for Drive backup targets.

    ComposeCaptureRequest:
      type: object
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          maxLength: 10000
        source:
          $ref: '#/components/schemas/CaptureComposeSource'
        override:
          $ref: '#/components/schemas/CaptureComposeOverride'
        context:
          $ref: '#/components/schemas/CaptureComposeContext'

    ComposeClassifierResult:
      type: object
      required: [classification, confidence]
      properties:
        classification:
          $ref: '#/components/schemas/CaptureClassification'
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
        reasons:
          type: array
          items:
            type: string
        debug:
          type: object
          description: Arbitrary diagnostic data from the classifier (structure may vary)
          additionalProperties: true
        captureClass:
          $ref: '#/components/schemas/CaptureClass'
        routing:
          $ref: '#/components/schemas/CaptureRouting'

    CaptureClass:
      type: string
      description: Deterministic 7-way capture classification (Det-Intel R3).
      enum: [task, note, goal, habit, routine, multi_item, ambiguous]

    CaptureRouting:
      type: object
      description: Deterministic routing decision for a capture under suggest-and-confirm (Det-Intel R4).
      required: [class, confidence, destination, autoApplied, reasonCode, reason, items]
      properties:
        class:
          $ref: '#/components/schemas/CaptureClass'
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
        destination:
          type: string
          enum: [tasks, goal_flow, habits, routines, review_split, library_reference, inbox_hold]
        autoApplied:
          type: boolean
        reasonCode:
          type: string
        reason:
          type: string
        items:
          type: array
          items:
            type: string

    ComposeTaskResult:
      type: object
      required: [id, title]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        dueDate:
          type: string
          format: date-time
        dueDateRaw:
          type: [string, 'null']
          description: Raw date-only token (YYYY-MM-DD) when the due date is all-day; clients use it to render the correct local day.
        dueDateHasTime:
          type: boolean
        priority:
          type: integer
        projectId:
          type: [string, 'null']
          format: uuid
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    ComposeCaptureEntityResult:
      type: object
      required: [id, content, classification, confidence]
      properties:
        id:
          type: string
          format: uuid
        content:
          type: string
        classification:
          $ref: '#/components/schemas/CaptureClassification'
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1

    ComposeCaptureResponse:
      type: object
      required: [classifier, finalEntityType, finalReasonCode]
      properties:
        classifier:
          $ref: '#/components/schemas/ComposeClassifierResult'
        finalEntityType:
          type: string
          enum: [TASK, CAPTURE, IGNORED]
        finalReasonCode:
          type: string
        task:
          $ref: '#/components/schemas/ComposeTaskResult'
        capture:
          $ref: '#/components/schemas/ComposeCaptureEntityResult'

    CaptureComposeOptionsResponse:
      type: object
      required: [captureTaskAutoThreshold, captureAutoConvertActionable]
      properties:
        captureTaskAutoThreshold:
          type: number
          format: float
          minimum: 0
          maximum: 1
        captureAutoConvertActionable:
          type: boolean
        autoOrganizeEnabled:
          type: boolean
          description: R4 auto-organize master toggle (default false → suggest-and-confirm).
        autoOrganizeCategories:
          type: object
          description: R4 per-category auto-organize flags keyed by CaptureClass.
          additionalProperties:
            type: boolean

    CaptureTextEmptyError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum: [CAPTURE_TEXT_EMPTY]
        message:
          type: string

    CaptureTextTooLongError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum: [CAPTURE_TEXT_TOO_LONG]
        message:
          type: string

    CaptureComposeFailedError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum: [CAPTURE_COMPOSE_FAILED]
        message:
          type: string

    # -----------------------------------------------------------------------
    # Capture inbox — unified item model (capture-to-triage flow)
    # -----------------------------------------------------------------------

    LifecycleState:
      type: string
      enum: [RAW_INBOX, ACTIVE, SCHEDULED, SNOOZED, ARCHIVED, COMPLETED, DELETED]
      description: |
        Lifecycle state of an inbox item driving the capture-to-triage state machine.
        - RAW_INBOX: newly arrived, awaiting triage
        - ACTIVE: promoted to an actionable task (no due date)
        - SCHEDULED: promoted to a task with a due date
        - SNOOZED: deferred — will resurface in the inbox after snoozeUntil
        - ARCHIVED: dismissed, moved to someday, or reclassified as note/reference
        - COMPLETED: underlying task was completed
        - DELETED: soft-deleted; not shown in normal lists

    ItemType:
      type: string
      enum: [RAW, TASK, NOTE, REFERENCE, SOMEDAY]
      description: |
        Semantic classification assigned to an inbox item after triage.
        - RAW: unclassified, awaiting review
        - TASK: promoted to an actionable task
        - NOTE: converted to a note (informational, not actionable)
        - REFERENCE: converted to reference material
        - SOMEDAY: moved to the someday/maybe list

    InboxItem:
      type: object
      required: [id, title, createdAt, lifecycleState, itemType]
      description: >
        Unified inbox item returned by GET /api/v1/captures/items and PATCH
        /api/v1/captures/{id}. Uses the richer lifecycle model rather than the
        legacy CaptureItem status field.
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
          maxLength: 500
          description: User-set or auto-derived title (first non-empty line of content)
        notes:
          oneOf:
            - type: string
            - type: 'null'
          description: Additional notes beyond the main content
        createdAt:
          type: string
          format: date-time
        source:
          type: string
          enum: [EMAIL, UI, API, ANDROID]
        lifecycleState:
          $ref: '#/components/schemas/LifecycleState'
        itemType:
          $ref: '#/components/schemas/ItemType'
        projectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Only set when itemType=TASK and a due date was specified
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: When the item will resurface after being snoozed
        archivedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        taskId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          description: ID of the task created when this item was promoted

    InboxItemsListResult:
      type: object
      required: [items, total, page, pageSize, hasMore]
      description: Paginated result from GET /api/v1/captures/items
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/InboxItem'
        total:
          type: integer
        page:
          type: integer
        pageSize:
          type: integer
        hasMore:
          type: boolean

    CaptureTransitionAction:
      type: string
      enum:
        - PROMOTE_TO_TASK
        - MOVE_TO_SOMEDAY
        - CONVERT_TO_NOTE
        - CONVERT_TO_REFERENCE
        - SNOOZE
        - ARCHIVE
        - DELETE
        - RESTORE_TO_INBOX
      description: |
        Lifecycle action to apply to an inbox item:
        - PROMOTE_TO_TASK: creates a real task; itemType→TASK, lifecycleState→ACTIVE (or SCHEDULED if dueDate supplied)
        - MOVE_TO_SOMEDAY: itemType→SOMEDAY, lifecycleState→ARCHIVED
        - CONVERT_TO_NOTE: itemType→NOTE, lifecycleState→ARCHIVED
        - CONVERT_TO_REFERENCE: itemType→REFERENCE, lifecycleState→ARCHIVED
        - SNOOZE: lifecycleState→SNOOZED; snoozeUntil is required
        - ARCHIVE: lifecycleState→ARCHIVED (generic dismiss)
        - DELETE: soft-deletes the item (lifecycleState→DELETED)
        - RESTORE_TO_INBOX: reverses a snooze or reclassification back to RAW_INBOX

    CaptureTransitionRequest:
      type: object
      required: [action]
      properties:
        action:
          $ref: '#/components/schemas/CaptureTransitionAction'
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Required when action=SNOOZE. ISO 8601 UTC datetime.
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Optional when action=PROMOTE_TO_TASK. Sets the task due date.

    CaptureTransitionResult:
      type: object
      required: [success]
      properties:
        success:
          type: boolean
        taskId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
          description: ID of the task created when action=PROMOTE_TO_TASK

    CaptureBatchTransitionRequest:
      type: object
      required: [itemIds, action]
      properties:
        itemIds:
          type: array
          items:
            type: string
            format: uuid
          minItems: 1
          description: IDs of the inbox items to transition
        action:
          $ref: '#/components/schemas/CaptureTransitionAction'
        snoozeUntil:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Required when action=SNOOZE
        dueDate:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
          description: Optional when action=PROMOTE_TO_TASK

    CaptureBatchTransitionResult:
      type: object
      required: [successes, failures, summary]
      description: >
        Per-item result from POST /api/v1/captures/batch-transition. Items that
        could not be transitioned (e.g. invalid lifecycle state) appear in
        failures without blocking the rest of the batch.
      properties:
        successes:
          type: array
          items:
            type: object
            required: [id]
            properties:
              id:
                type: string
                format: uuid
              taskId:
                oneOf:
                  - type: string
                    format: uuid
                  - type: 'null'
                description: Created task ID when action=PROMOTE_TO_TASK
        failures:
          type: array
          items:
            type: object
            required: [id, code, message]
            properties:
              id:
                type: string
                format: uuid
              code:
                type: string
                description: Machine-readable error code
              message:
                type: string
                description: Human-readable error description
        summary:
          type: object
          required: [requested, succeeded, failed]
          properties:
            requested:
              type: integer
            succeeded:
              type: integer
            failed:
              type: integer

    CaptureItemUpdateRequest:
      type: object
      description: Request body for PATCH /api/v1/captures/{id}
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
        notes:
          type: string
          maxLength: 10000

    CleanupRunStatus:
      type: string
      enum: [DRAFT, REVIEWED, APPLIED, UNDONE, EXPIRED]

    CleanupRunScope:
      type: string
      enum:
        - read_run
        - write_decisions
        - apply_run
        - undo_run
        - set_project
        - set_tags
        - set_due_date
        - set_priority
        - convert_to_task
        - archive_item

    CleanupRun:
      type: object
      required: [id, userId, status, source, totalItems, appliedItems, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/CleanupRunStatus'
        source:
          type: string
          enum: [INBOX, SETTINGS, API]
        totalItems:
          type: integer
        appliedItems:
          type: integer
        batchId:
          type: [string, 'null']
        expiresAt:
          type: [string, 'null']
          format: date-time
        appliedAt:
          type: [string, 'null']
          format: date-time
        undoneAt:
          type: [string, 'null']
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CleanupRunItem:
      type: object
      required: [id, runId, userId, captureId, decisionState, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        runId:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        captureId:
          type: string
          format: uuid
        proposalAction:
          type: [string, 'null']
        proposalProjectId:
          type: [string, 'null']
          format: uuid
        proposalDueDate:
          type: [string, 'null']
          format: date-time
        proposalPriority:
          type: [integer, 'null']
        proposalTagsJson:
          type: [array, 'null']
          items:
            type: string
        proposalConfidence:
          type: [number, 'null']
        proposalReasonsJson:
          type: [array, 'null']
          items:
            type: object
            additionalProperties: true
        snapshotJson:
          type: [object, 'null']
          additionalProperties: true
        decisionState:
          type: string
          enum: [PENDING, ACCEPTED, REJECTED]
        decisionAction:
          type: [string, 'null']
        decisionProjectId:
          type: [string, 'null']
          format: uuid
        decisionDueDate:
          type: [string, 'null']
          format: date-time
        decisionPriority:
          type: [integer, 'null']
        decisionTagsJson:
          type: [array, 'null']
          items:
            type: string
        decisionNotes:
          type: [string, 'null']
        appliedAt:
          type: [string, 'null']
          format: date-time
        resultTaskId:
          type: [string, 'null']
          format: uuid
        errorMessage:
          type: [string, 'null']
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        capture:
          type: object
          additionalProperties: true

    CleanupRunToken:
      type: object
      required: [id, runId, scopesJson, expiresAt, createdAt]
      properties:
        id:
          type: string
          format: uuid
        runId:
          type: string
          format: uuid
        scopesJson:
          type: array
          items:
            $ref: '#/components/schemas/CleanupRunScope'
        expiresAt:
          type: string
          format: date-time
        revokedAt:
          type: [string, 'null']
          format: date-time
        lastUsedAt:
          type: [string, 'null']
          format: date-time
        createdAt:
          type: string
          format: date-time

    CleanupRunWithItems:
      allOf:
        - $ref: '#/components/schemas/CleanupRun'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/CleanupRunItem'
            tokens:
              type: array
              items:
                $ref: '#/components/schemas/CleanupRunToken'

    CleanupRunCreateRequest:
      type: object
      properties:
        itemIds:
          type: array
          maxItems: 200
          items:
            type: string
            format: uuid
        limit:
          type: integer
          minimum: 1
          maximum: 200
        source:
          type: string
          enum: [INBOX, SETTINGS, API]

    CleanupRunItemDecisionRequest:
      type: object
      required: [decisionState]
      properties:
        decisionState:
          type: string
          enum: [PENDING, ACCEPTED, REJECTED]
        decisionAction:
          $ref: '#/components/schemas/CaptureTransitionAction'
        decisionProjectId:
          type: [string, 'null']
          format: uuid
        decisionDueDate:
          type: [string, 'null']
          format: date-time
        decisionPriority:
          type: [integer, 'null']
          minimum: 0
          maximum: 4
        decisionTags:
          type: array
          maxItems: 20
          items:
            type: string
        decisionNotes:
          type: string
          maxLength: 1000

    CleanupRunApplyResult:
      type: object
      required: [success, batchId, applied, failures]
      properties:
        success:
          type: boolean
        batchId:
          type: string
        applied:
          type: array
          items:
            type: object
            required: [itemId]
            properties:
              itemId:
                type: string
                format: uuid
              taskId:
                type: [string, 'null']
                format: uuid
        failures:
          type: array
          items:
            type: object
            required: [itemId, error]
            properties:
              itemId:
                type: string
                format: uuid
              error:
                type: string

    CleanupRunUndoResult:
      type: object
      required: [success, restoredCaptureIds, removedTaskIds]
      properties:
        success:
          type: boolean
        restoredCaptureIds:
          type: array
          items:
            type: string
            format: uuid
        removedTaskIds:
          type: array
          items:
            type: string
            format: uuid

    CleanupRunTokenCreateRequest:
      type: object
      required: [scopes]
      properties:
        scopes:
          type: array
          minItems: 1
          maxItems: 20
          items:
            $ref: '#/components/schemas/CleanupRunScope'
        ttlMinutes:
          type: integer
          minimum: 5
          maximum: 240

    CleanupRunTokenCreateResponse:
      allOf:
        - $ref: '#/components/schemas/CleanupRunToken'
        - type: object
          required: [token, tokenType]
          properties:
            token:
              type: string
            tokenType:
              type: string
              enum: [cleanup_run]

    CleanupRunExternalDecisionBatchRequest:
      type: object
      required: [updates]
      properties:
        updates:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: object
            required: [itemId, decisionState]
            properties:
              itemId:
                type: string
                format: uuid
              decisionState:
                type: string
                enum: [PENDING, ACCEPTED, REJECTED]
              decisionAction:
                $ref: '#/components/schemas/CaptureTransitionAction'
              decisionProjectId:
                type: [string, 'null']
                format: uuid
              decisionDueDate:
                type: [string, 'null']
                format: date-time
              decisionPriority:
                type: [integer, 'null']
              decisionTags:
                type: array
                items:
                  type: string

    # -----------------------------------------------------------------------
    # Phase 3: Settings schemas
    # -----------------------------------------------------------------------
    PortablePreferences:
      type: [object, 'null']
      additionalProperties: false
      description: >-
        Versioned account-scoped, user-visible preferences that roam across the
        authenticated user's web, PWA, and Android clients. Credentials and raw
        behavioral history are intentionally excluded.
      properties:
        schemaVersion:
          type: integer
          enum: [1]
        activeHouseholdId:
          type: [string, 'null']
          format: uuid
        accessibility:
          type: object
        androidTheme:
          type: object
        focusTimer:
          type: object
        androidAppearance:
          type: object
        general:
          type: object
        workspace:
          type: object
        projectCreation:
          type: object
        backup:
          type: object

    DevicePreferencesUpdate:
      type: object
      additionalProperties: false
      description: >-
        Versioned user-visible preferences stored for one stable device only.
        The service never lists or applies this data to another device.
      properties:
        schemaVersion:
          type: integer
          enum: [1]
        notifications:
          type: object
          properties:
            muted:
              type: boolean
            mutedProjectIds:
              type: array
              maxItems: 500
              items:
                type: string
                format: uuid
            privacyMode:
              type: string
              enum: [full, hide_on_lock_screen, hide_everywhere]
        integrations:
          type: object
          properties:
            androidAutoEnabled:
              type: boolean
            externalTaskIntegrationsEnabled:
              type: boolean
        performance:
          type: object
          properties:
            lowEndDeviceMode:
              type: boolean
        widgets:
          type: object
          additionalProperties:
            type: object
            additionalProperties:
              type: string

    DevicePreferencesResponse:
      type: object
      required: [deviceId, settings, updatedAt]
      properties:
        deviceId:
          type: string
          description: Opaque stable device identifier supplied by the authenticated client.
        settings:
          $ref: '#/components/schemas/DevicePreferencesUpdate'
        updatedAt:
          type: [string, 'null']
          format: date-time

    UserSettingsBase:
      type: object
      description: Common writable user preference fields.
      properties:
        timezone:
          type: string
          example: America/New_York
        theme:
          type: string
          enum: [light, dark, auto, system]
        language:
          type: string
          example: en
        defaultView:
          type: string
        startPageView:
          type: string
        hapticFeedback:
          type: boolean
        syncInterval:
          type: integer
          description: Sync interval in minutes
        autoSync:
          type: boolean
        syncOnlyOnWifi:
          type: boolean
        conflictResolution:
          type: string
          enum: [last_write_wins, remote_wins, local_wins, manual, local, remote, ask]
        portablePreferences:
          $ref: '#/components/schemas/PortablePreferences'
        aiProvider:
          type: string
          enum: [on_device, gemini, openai, anthropic, qwen, groq, perplexity]
          description: '#1463 #20: AI data-mode / provider selection.'
        cloudAiConsent:
          type: boolean
          description: '#1463 #20: explicit opt-in required to select a cloud provider.'
        cloudAiConsentAt:
          type: [string, 'null']
          format: date-time
          readOnly: true
          description: '#1463 #20: response-only mirror of the stored consent timestamp.'
        aiSelectedModel:
          type: string
          minLength: 1
          maxLength: 200
          description: '#2376: selected on-device AI model, synced across account devices.'
        aiSelectedCloudModels:
          type: object
          maxProperties: 7
          propertyNames:
            minLength: 1
            maxLength: 32
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 200
          description: '#2376: complete provider-to-model selection map.'
        encryptedSearchEnabled:
          type: boolean
          description: '#2376: account-level encrypted-search feature preference.'
        encryptedShareEnabled:
          type: boolean
          description: '#2376: account-level encrypted-sharing feature preference.'
        onboardingCompleted:
          type: boolean
        sessionLogging:
          type: boolean
        captureAutoConvertActionable:
          type: boolean
          description: Allow auto-conversion of TASK-classified composer input when threshold is met.
        autoOrganizeEnabled:
          type: boolean
          description: R4 auto-organize master toggle (default false → suggest-and-confirm).
        autoOrganizeCategories:
          type: object
          additionalProperties:
            type: boolean
          description: R4 per-category auto-organize flags keyed by CaptureClass.
        workflowMode:
          type: string
          enum: [CAPTURE_FIRST, TASKS_ONLY]
          description: Controls whether Inbox is visible and reachable. CAPTURE_FIRST shows Inbox tab; TASKS_ONLY hides it.
          example: CAPTURE_FIRST
        libraryLastTab:
          oneOf:
            - type: string
              enum: [someday, notes, reference, archive]
            - type: 'null'
        libraryLastSort:
          oneOf:
            - type: string
              enum: [oldest, newest]
            - type: 'null'
        features:
          type: object
          description: Feature flag overrides for this user
        inboxAssistEnabled:
          type: boolean
          default: true
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistStrongThreshold:
          type: number
          format: float
          default: 0.7
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistMinStrongCount:
          type: integer
          default: 5
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistMinTotalCount:
          type: integer
          default: 12
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistCooldownHours:
          type: integer
          default: 24
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        # Configurable-options pack (#config-14). Persisted in the settings JSON blob and
        # roamed across web + Android. Loosely typed here (like `features`); the Zod schema
        # UserSettingsSchema in src/taptidy/api/schemas.ts is the authoritative shape.
        soundRules:
          type: [object, 'null']
          description: 'F1: per-list/tag/household custom notification sounds (bundled preset ids).'
        ledRules:
          type: [object, 'null']
          description: 'F2: per-list/tag notification LED / accent colors.'
        prioritySensory:
          type: [object, 'null']
          description: 'F3: per-priority vibration/sound/intensity escalation.'
        vibrationRules:
          type: [object, 'null']
          description: 'F4: roamed vibration rule settings (also JSON export/import).'
        geofence:
          type: [object, 'null']
          description: 'F5: leveled geofence reminders (off|partial|full) + per-list-or-per-errand rules + monitor accuracy (balanced|responsive).'
        listNotificationProfiles:
          type: object
          description: 'F6: per-list notification profile + quiet-hours/schedule, keyed by projectId.'
        energyRules:
          type: array
          items:
            type: object
          description: 'F7: energy → task-view auto-rules.'
        themeSchedule:
          type: [object, 'null']
          description: 'F8: time-of-day adaptive theming.'
        listTaskDefaults:
          type: object
          description: 'F9: per-list new-task field defaults, keyed by projectId.'
        listAutoArchive:
          type: object
          description: 'F10: per-list auto-archive of completed tasks after N days, keyed by projectId.'
        nlpKeywords:
          type: array
          items:
            type: object
          description: 'F11: custom quick-add NLP token expansions.'
        keyboardShortcuts:
          type: object
          description: 'F12: web keyboard shortcut remapping, keyed by action id.'
        notificationPreferences:
          type: object
          description: >-
            #2043/#2056: unified account-level notification preferences. Mirrors Android's
            NotificationSettings field-for-field (that is the authoritative shape); replaces
            the retired notificationPolicy blob. Whole-key last-write-wins on PATCH, same
            semantics as listNotificationProfiles.
          properties:
            notificationsEnabled:
              type: boolean
              default: true
            stickyNotifications:
              type: boolean
              default: false
            swipeToSnoozeEnabled:
              type: boolean
              default: false
            snoozeDelayMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 10
            defaultReminderBehavior:
              type: string
              enum: [at_due_time]
              default: at_due_time
            undoDurationSeconds:
              type: integer
              minimum: 0
              maximum: 60
              default: 5
            swipeDirectionConfig:
              type: string
              enum: [default, todoist, reversed, snooze_complete, delete_complete]
              default: default
            multiSelectOptions:
              type: object
              properties:
                moveDates:
                  type: boolean
                  default: true
                editTags:
                  type: boolean
                  default: true
                changeProject:
                  type: boolean
                  default: true
            notificationActionOrder:
              type: string
              enum: [complete_then_snooze, snooze_then_complete]
              default: complete_then_snooze
            allDayReminderEnabled:
              type: boolean
              default: true
            allDayReminderHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 18
            allDayReminderMinute:
              type: integer
              minimum: 0
              maximum: 59
              default: 0
            vibrationEnabled:
              type: boolean
              default: true
            escalationEnabled:
              type: boolean
              default: true
            escalationSecondStageMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 30
            escalationThirdStageMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 120
            escalationActionableSummaryEnabled:
              type: boolean
              default: true
            outcomeActionsEnabled:
              type: boolean
              default: true
            outcomeActionMoveTonightEnabled:
              type: boolean
              default: true
            outcomeActionRescheduleEnabled:
              type: boolean
              default: true
            priorityChannelsEnabled:
              type: boolean
              default: true
            urgentPriorityThreshold:
              type: integer
              minimum: 0
              maximum: 4
              default: 2
            quietHoursEnabled:
              type: boolean
              default: false
            quietHoursStartHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 22
            quietHoursEndHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 7
            staleTaskResurfaceEnabled:
              type: boolean
              default: true
            staleTaskSnoozeThreshold:
              type: integer
              minimum: 0
              maximum: 100
              default: 3
            staleTaskResurfaceDelayMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 30
            weeklyFeedbackEnabled:
              type: boolean
              default: true
            weeklyFeedbackDayOfWeek:
              type: integer
              minimum: 1
              maximum: 7
              default: 1
            weeklyFeedbackHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 9
            crossDeviceDedupeEnabled:
              type: boolean
              default: true
            crossDeviceDedupeWindowSeconds:
              type: integer
              minimum: 0
              maximum: 3600
              default: 90
            vibrationPatternPreset:
              type: string
              enum: [short, medium, long, triple_short_medium_long, custom]
              default: medium
            customVibrationPattern:
              type: string
              maxLength: 200
              default: '100,100,100,200,400'
            taskCompletionNotificationsEnabled:
              type: boolean
              default: false
            quickAddNotificationEnabled:
              type: boolean
              default: false
            quickAddKeepPinned:
              type: boolean
              default: false
            quickAddRepostCooldownMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 30
            quickAddNoReshowUntilNextDay:
              type: boolean
              default: false
            quickAddSmartDefault:
              type: string
              enum: [inbox, last_used_list]
              default: inbox
            quickAddChannelVibration:
              type: boolean
              default: false
            quickAddChannelSound:
              type: boolean
              default: false
            quickAddAnalyticsEnabled:
              type: boolean
              default: true
            quickAddSuccessConfirmationEnabled:
              type: boolean
              default: true
              description: 'Gates the "Task added" confirmation notification shown after a successful inline Quick Add submission.'
            householdTaskActivityNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household task created/updated/deleted/assigned/claimed/completed-by-other and rotation-turn-changed pushes.'
            householdApprovalNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household task approval requested/approved/declined/reopened/unassigned pushes.'
            householdRewardsGoalsNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household reward redemption requested/resolved and shared goal reached pushes.'
            householdMembershipNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household invite sent/accepted, member removed, and ownership received/transferred pushes.'
            notificationProfile:
              type: string
              enum: [gentle, standard, persistent]
              default: standard
            notificationsCustomized:
              type: boolean
              default: false
            swipeRightAction:
              type: string
              enum: [complete, snooze, delete, schedule, multi_select, needs_clarification]
              default: complete
            swipeLeftAction:
              type: string
              enum: [complete, snooze, delete, schedule, multi_select, needs_clarification]
              default: delete
            mutedProjectIds:
              type: array
              items:
                type: string
              maxItems: 500
              description: 'QOL-D3: per-list notification muting — project ids for which reminders are suppressed.'
            privacyMode:
              type: string
              enum: [full, hide_on_lock_screen, hide_everywhere]
              default: full
              description: '#1754: lock-screen/outside-app content privacy.'
            habitStreakBrokenNotificationsEnabled:
              type: boolean
              default: false
              description: 'Off by default (opt-in, not opt-out).'
            habitStreakBrokenNotificationMode:
              type: string
              enum: [immediate, digest]
              default: digest
              description: "'immediate' fires only for a deliberate skip; 'digest' rolls up both skips and missed occurrences into one daily notification."
            habitStreakBrokenDigestHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 9
        themeSync:
          type: [object, 'null']
          description: >-
            #2068: account-scoped theme sync -- resolved mode + primary/accent hex
            + optional web preset id, the common denominator between web's named
            presets and Android's raw custom colors. Whole-key last-write-wins,
            same semantics as notificationPreferences/themeSchedule.
          properties:
            mode:
              type: string
              enum: [light, dark, system]
            primaryColor:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
            accentColor:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
            presetId:
              type: [string, 'null']
              maxLength: 64
        navigationSettings:
          type: [object, 'null']
          description: >-
            #2191: web sidebar customization (hide/show + reorder), account-synced
            port of Android's NavItemRegistry order/hidden model. Section-key arrays;
            unknown/stale keys are reconciled client-side, not validated server-side.
          properties:
            order:
              type: array
              items:
                type: string
            hidden:
              type: array
              items:
                type: string
        diagnostics:
          type: object
          description: QOL-20 Android OEM Battery Kill Diagnostics
          properties:
            lastWorkerRun:
              oneOf:
                - type: string
                  format: date-time
                - type: 'null'
            lastWorkerDelay:
              oneOf:
                - type: integer
                - type: 'null'
              description: Last worker delay in milliseconds
            deviceManufacturer:
              oneOf:
                - type: string
                - type: 'null'
            batteryOptimizationState:
              oneOf:
                - type: string
                  enum: [unknown, optimized, unrestricted, restricted]
                - type: 'null'
        guidanceMode:
          type: string
          enum: [recommended, manual]
          default: recommended
          description: QOL-22 Guidance mode setting
        focusOverloadWarningEnabled:
          type: boolean
          default: true
          description: Show the Focus Overload warning when task count exceeds 7
        energyOverride:
          oneOf:
            - type: string
              enum: [low, medium, high]
            - type: 'null'
          description: '#615 one-tap energy override feeding focus scoring; null = inferred'
        e2eeEnabled:
          type: boolean
          default: false
          description: Whether E2EE is enabled for this user
        zapierWebhookUrl:
          oneOf:
            - type: string
              format: uri
            - type: 'null'
          description: User's Zapier webhook URL for outbound events
        zapierOutboundWebhookSecret:
          oneOf:
            - type: string
            - type: 'null'
          description: >-
            Device-synced HMAC signing secret for Android's outbound Zapier webhook
            dispatch (#1363). Distinct from zapierWebhookSecret, which is the inbound
            Zapier subscription secret and stays writeOnly.
        realtimeMode:
          type: string
          enum: [fcm, websocket, both, unifiedpush, up_ws]
          default: fcm
          description: Push notification transport — fcm (default), websocket (nogoogle), both, unifiedpush, or up_ws (UnifiedPush primary, WebSocket fallback — nogoogle auto)
        androidAutoEnabled:
          type: boolean
          default: false
          description: Whether Android Auto integration is enabled
        onboardingAndroidCompleted:
          type: boolean
          description: Whether the Android onboarding flow has been completed
        offlineModeEnabled:
          type: boolean
          description: Whether offline-only mode is enabled
        use24HourTime:
          oneOf:
            - type: boolean
            - type: 'null'
          description: Whether 24-hour time format is preferred (null/absent = match system locale)
        hideTaskRoulette:
          type: boolean
          default: false
          description: Hide Task Roulette entry points across clients
        defaultReminderMinutesBefore:
          oneOf:
            - type: integer
              minimum: -1
              maximum: 10080
            - type: 'null'
          default: 15
          description: >-
            Minutes before a task's due date+time to auto-attach an alarm at
            create time, for tasks created with no explicit reminder. -1 = the
            app's "no reminder" sentinel (explicitly off); null/absent = never
            configured, server normalizes to 15.
        hideTaskExamples:
          type: boolean
          default: false
          description: Hide the one-time starter-examples prompt for Tasks (#2236)
        hideHabitExamples:
          type: boolean
          default: false
          description: Hide the one-time starter-examples prompt for Habits (#2236)
        hideChoreExamples:
          type: boolean
          default: false
          description: Hide the one-time starter-examples prompt for Household Chores (#2236)
        hideNonDueHouseholdTasksToday:
          type: boolean
          default: false
          description: >-
            Hide other members' household tasks/chores from the household
            Today view unless they're due today or overdue. Per-user
            preference, not household-wide.
        externalTaskIntegrationsEnabled:
          type: boolean
          default: false
          description: >-
            Whether external task integrations (Android exported ContentProvider)
            are enabled. Fails closed: absent means false; clients push but never
            auto-enable from a pulled value.
        localFirstTasksEnabled:
          type: boolean
          default: false
          description: |
            Local-first task engine (#1668). Per-account opt-in, default off. When on,
            the client treats its local store as the source of truth and syncs through
            the ordered change log instead of the legacy delta path.
        showHabitsHeadsUp:
          type: boolean
          default: true
          description: Show the Today habits heads-up notice across clients
        calmListView:
          type: boolean
          default: false
          description: Calm list view — hide nudges, promos, and idle filter chips on Today across clients (#1733)
        defaultSort:
          type: string
          enum: [manual, dueDate, priority, created, alphabetical]
          description: Personal task-list sort mode (#1729). Persisted in the settings JSON blob.
        hideTodayFocusCard:
          type: boolean
          default: false
          description: Hide the Today Focus Session card (dismissed by the user), synced across clients
        hideTodayHabitsCard:
          type: boolean
          default: false
          description: Hide the Today Habits card (dismissed by the user), synced across clients
        cookieConsent:
          type: object
          description: Cookie consent preferences
        syncEnabled:
          type: boolean
          description: Legacy alias for autoSync
        statsEnabled:
          type: boolean
          description: Whether stats tracking is enabled
        collapseListsByDefault:
          type: boolean
          description: Whether lists are collapsed by default
        collapseTagsByDefault:
          type: boolean
          description: Whether tags are collapsed by default
        showListCounts:
          type: boolean
          description: Whether to show list item counts
        browseListFavIds:
          type: array
          items:
            oneOf:
              - type: string
              - type: 'null'
        browseTagFavIds:
          type: array
          items:
            type: string
        inboxShowBadgeCount:
          type: boolean
          description: Whether to show badge count on inbox
        inboxWeeklyReminderEnabled:
          type: boolean
        inboxWeeklyReminderWeekday:
          type: integer
          minimum: 0
          maximum: 6
        inboxAdaptiveEscalationThreshold:
          type: integer
          minimum: 1
          maximum: 1000
        inboxReviewSessionMode:
          type: boolean
        inboxReminderThreshold:
          type: integer
          minimum: 1
          maximum: 1000
        emailPriorityMappings:
          type: array
          items:
            type: object
        zapierWebhookSecret:
          oneOf:
            - type: string
            - type: 'null'
          writeOnly: true
        libraryLastProjectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        librarySavedWorkflows:
          type: array
          items:
            type: object
        wasmEnabled:
          type: boolean
        wasmKillSwitch:
          type: boolean
        clarityTelemetryEnabled:
          type: boolean
        clarityCoachingEnabled:
          type: boolean
          default: true
          description: Live coaching tips on task capture (on/off), synced account-wide. Independent of clarityTelemetryEnabled (analytics only).
        clarityThreshold:
          type: number
          minimum: 0
          maximum: 1
          default: 0.50
          description: Clarity score threshold below which a task is flagged needsClarification.
        clarityReviewEnabled:
          type: boolean
          default: true
          description: Clarity review digest enabled, synced account-wide.
        clarityReviewFrequency:
          type: string
          enum: [DAILY, EVERY_2_DAYS, WEEKLY, "OFF"]
          default: DAILY
          description: Frequency for clarity review digests.
        clarityReviewTime:
          type: string
          pattern: '^([01]\d|2[0-3]):[0-5]\d$'
          default: "20:00"
          description: Time of day (HH:mm) for clarity review digests.
        accessibilityHighContrast:
          type: boolean
          default: false
          description: High contrast mode for accessibility.
        accessibilityDyslexiaText:
          type: boolean
          default: false
          description: Dyslexia-friendly font mode for accessibility.
        accessibilityColorBlindness:
          type: string
          enum: [none, protanopia, deuteranopia, tritanopia]
          default: none
          description: Color blindness palette adjustment.
        accessibilityTouchTargetScale:
          type: string
          enum: [standard, large]
          default: standard
          description: Minimum touch target scaling.
        accessibilityCalmMode:
          type: boolean
          default: false
          description: Calm mode suppressing animations and sounds.
        accessibilityReduceAutoplay:
          type: boolean
          default: false
          description: Suppress automatic media and animation playback.
        accessibilitySimplifiedLanguage:
          type: boolean
          default: false
          description: Simplified language and microcopy.
        taskCardCompletionAnimation:
          type: string
          enum: [fade, strikethrough_fade, checkmark_bounce, none]
          default: strikethrough_fade
          description: Visual animation when completing a task card.
        taskCardOverdueStyle:
          type: string
          enum: [red_date_text, red_left_border, red_background_tint, none]
          default: red_date_text
          description: Visual indicator style for overdue task cards.
        taskCardPriorityIndicator:
          type: string
          enum: [colored_dot, colored_left_bar, colored_text, icon, none]
          default: colored_dot
          description: Visual priority indicator style on task cards.
        timerSkinId:
          type: string
          enum: [default, matcha, lofi, forest, tomato, sakura]
          default: default
          description: Selected visual skin for the web pomodoro timer.
        routinesCalendarEnabled:
          type: boolean
          default: true
          description: Routine calendar (#1641) — server-driven, GA default on; false hides the surface.
        defaultDueHour:
          type: integer
          minimum: 0
          maximum: 23
          default: 9
          description: Default hour (0-23) for new task due times. Roams across clients.
        defaultDueMinute:
          type: integer
          minimum: 0
          maximum: 59
          default: 0
          description: Default minute (0-59) for new task due times. Roams across clients.
        tabSortState:
          type: object
          additionalProperties:
            type: string
          description: Per-tab sort selection map. Roams across clients.
        recentSearches:
          type: array
          items:
            type: string
          description: Recently used search queries. Roams across clients.
        recentTags:
          type: array
          items:
            type: string
          description: Recently used tags. Roams across clients.

    SettingsConflictResponse:
      type: object
      required: [code, message, settingsRevision]
      description: >-
        Returned when a settings write carries a stale If-Match revision. The
        write was not applied; re-read the settings and reapply the change.
      properties:
        code:
          type: string
          enum: [SETTINGS_CONFLICT]
        message:
          type: string
        settingsRevision:
          type: integer
          minimum: 0
          description: The revision currently stored for this account.

    UserSettings:
      type: object
      description: User settings response payload.
      properties:
        settingsRevision:
          type: integer
          minimum: 0
          description: >-
            Optimistic-concurrency token for this account's settings. Echo it
            back as If-Match on a write to have a stale write rejected.
        timezone:
          type: string
          example: America/New_York
        theme:
          type: string
          enum: [light, dark, auto, system]
        language:
          type: string
          example: en
        defaultView:
          type: string
        startPageView:
          type: string
        hapticFeedback:
          type: boolean
        syncInterval:
          type: integer
          description: Sync interval in minutes
        autoSync:
          type: boolean
        syncOnlyOnWifi:
          type: boolean
        conflictResolution:
          type: string
          enum: [last_write_wins, remote_wins, local_wins, manual, local, remote, ask]
        portablePreferences:
          $ref: '#/components/schemas/PortablePreferences'
        aiProvider:
          type: string
          enum: [on_device, gemini, openai, anthropic, qwen, groq, perplexity]
          description: '#1463 #20: selected AI data-mode / provider. Default on_device.'
        cloudAiConsentAt:
          type: [string, 'null']
          format: date-time
          readOnly: true
          description: '#1463 #20: timestamp of explicit opt-in to off-device AI. Null = on-device only.'
        cloudAiConsent:
          type: boolean
          description: '#1463 #20: response mirror; explicit opt-in flag (write-only on update).'
        aiSelectedModel:
          type: string
          minLength: 1
          maxLength: 200
          description: '#2376: selected on-device AI model, synced across account devices.'
        aiSelectedCloudModels:
          type: object
          maxProperties: 7
          propertyNames:
            minLength: 1
            maxLength: 32
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 200
          description: '#2376: complete provider-to-model selection map.'
        encryptedSearchEnabled:
          type: boolean
          description: '#2376: account-level encrypted-search feature preference.'
        encryptedShareEnabled:
          type: boolean
          description: '#2376: account-level encrypted-sharing feature preference.'
        onboardingCompleted:
          type: boolean
        sessionLogging:
          type: boolean
        captureAutoConvertActionable:
          type: boolean
          description: Allow auto-conversion of TASK-classified composer input when threshold is met.
        autoOrganizeEnabled:
          type: boolean
          description: R4 auto-organize master toggle (default false → suggest-and-confirm).
        autoOrganizeCategories:
          type: object
          additionalProperties:
            type: boolean
          description: R4 per-category auto-organize flags keyed by CaptureClass.
        workflowMode:
          type: string
          enum: [CAPTURE_FIRST, TASKS_ONLY]
          description: Controls whether Inbox is visible and reachable. CAPTURE_FIRST shows Inbox tab; TASKS_ONLY hides it.
          example: CAPTURE_FIRST
        libraryLastTab:
          oneOf:
            - type: string
              enum: [someday, notes, reference, archive]
            - type: 'null'
        libraryLastSort:
          oneOf:
            - type: string
              enum: [oldest, newest]
            - type: 'null'
        features:
          type: object
          description: Feature flag overrides for this user
        inboxAssistEnabled:
          type: boolean
          default: true
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistStrongThreshold:
          type: number
          format: float
          default: 0.7
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistMinStrongCount:
          type: integer
          default: 5
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistMinTotalCount:
          type: integer
          default: 12
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        inboxAssistCooldownHours:
          type: integer
          default: 24
          deprecated: true
          description: Deprecated compatibility field; ignored by current clients.
        # Configurable-options pack (#config-14). Persisted in the settings JSON blob and
        # roamed across web + Android. Loosely typed here (like `features`); the Zod schema
        # UserSettingsSchema in src/taptidy/api/schemas.ts is the authoritative shape.
        soundRules:
          type: [object, 'null']
          description: 'F1: per-list/tag/household custom notification sounds (bundled preset ids).'
        ledRules:
          type: [object, 'null']
          description: 'F2: per-list/tag notification LED / accent colors.'
        prioritySensory:
          type: [object, 'null']
          description: 'F3: per-priority vibration/sound/intensity escalation.'
        vibrationRules:
          type: [object, 'null']
          description: 'F4: roamed vibration rule settings (also JSON export/import).'
        geofence:
          type: [object, 'null']
          description: 'F5: leveled geofence reminders (off|partial|full) + per-list-or-per-errand rules + monitor accuracy (balanced|responsive).'
        listNotificationProfiles:
          type: object
          description: 'F6: per-list notification profile + quiet-hours/schedule, keyed by projectId.'
        energyRules:
          type: array
          items:
            type: object
          description: 'F7: energy → task-view auto-rules.'
        themeSchedule:
          type: [object, 'null']
          description: 'F8: time-of-day adaptive theming.'
        listTaskDefaults:
          type: object
          description: 'F9: per-list new-task field defaults, keyed by projectId.'
        listAutoArchive:
          type: object
          description: 'F10: per-list auto-archive of completed tasks after N days, keyed by projectId.'
        nlpKeywords:
          type: array
          items:
            type: object
          description: 'F11: custom quick-add NLP token expansions.'
        keyboardShortcuts:
          type: object
          description: 'F12: web keyboard shortcut remapping, keyed by action id.'
        notificationPreferences:
          type: object
          description: >-
            #2043/#2056: unified account-level notification preferences. Mirrors Android's
            NotificationSettings field-for-field (that is the authoritative shape); replaces
            the retired notificationPolicy blob. Whole-key last-write-wins on PATCH, same
            semantics as listNotificationProfiles.
          properties:
            notificationsEnabled:
              type: boolean
              default: true
            stickyNotifications:
              type: boolean
              default: false
            swipeToSnoozeEnabled:
              type: boolean
              default: false
            snoozeDelayMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 10
            defaultReminderBehavior:
              type: string
              enum: [at_due_time]
              default: at_due_time
            undoDurationSeconds:
              type: integer
              minimum: 0
              maximum: 60
              default: 5
            swipeDirectionConfig:
              type: string
              enum: [default, todoist, reversed, snooze_complete, delete_complete]
              default: default
            multiSelectOptions:
              type: object
              properties:
                moveDates:
                  type: boolean
                  default: true
                editTags:
                  type: boolean
                  default: true
                changeProject:
                  type: boolean
                  default: true
            notificationActionOrder:
              type: string
              enum: [complete_then_snooze, snooze_then_complete]
              default: complete_then_snooze
            allDayReminderEnabled:
              type: boolean
              default: true
            allDayReminderHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 18
            allDayReminderMinute:
              type: integer
              minimum: 0
              maximum: 59
              default: 0
            vibrationEnabled:
              type: boolean
              default: true
            escalationEnabled:
              type: boolean
              default: true
            escalationSecondStageMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 30
            escalationThirdStageMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 120
            escalationActionableSummaryEnabled:
              type: boolean
              default: true
            outcomeActionsEnabled:
              type: boolean
              default: true
            outcomeActionMoveTonightEnabled:
              type: boolean
              default: true
            outcomeActionRescheduleEnabled:
              type: boolean
              default: true
            priorityChannelsEnabled:
              type: boolean
              default: true
            urgentPriorityThreshold:
              type: integer
              minimum: 0
              maximum: 4
              default: 2
            quietHoursEnabled:
              type: boolean
              default: false
            quietHoursStartHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 22
            quietHoursEndHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 7
            staleTaskResurfaceEnabled:
              type: boolean
              default: true
            staleTaskSnoozeThreshold:
              type: integer
              minimum: 0
              maximum: 100
              default: 3
            staleTaskResurfaceDelayMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 30
            weeklyFeedbackEnabled:
              type: boolean
              default: true
            weeklyFeedbackDayOfWeek:
              type: integer
              minimum: 1
              maximum: 7
              default: 1
            weeklyFeedbackHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 9
            crossDeviceDedupeEnabled:
              type: boolean
              default: true
            crossDeviceDedupeWindowSeconds:
              type: integer
              minimum: 0
              maximum: 3600
              default: 90
            vibrationPatternPreset:
              type: string
              enum: [short, medium, long, triple_short_medium_long, custom]
              default: medium
            customVibrationPattern:
              type: string
              maxLength: 200
              default: '100,100,100,200,400'
            taskCompletionNotificationsEnabled:
              type: boolean
              default: false
            quickAddNotificationEnabled:
              type: boolean
              default: false
            quickAddKeepPinned:
              type: boolean
              default: false
            quickAddRepostCooldownMinutes:
              type: integer
              minimum: 0
              maximum: 1440
              default: 30
            quickAddNoReshowUntilNextDay:
              type: boolean
              default: false
            quickAddSmartDefault:
              type: string
              enum: [inbox, last_used_list]
              default: inbox
            quickAddChannelVibration:
              type: boolean
              default: false
            quickAddChannelSound:
              type: boolean
              default: false
            quickAddAnalyticsEnabled:
              type: boolean
              default: true
            quickAddSuccessConfirmationEnabled:
              type: boolean
              default: true
              description: 'Gates the "Task added" confirmation notification shown after a successful inline Quick Add submission.'
            householdTaskActivityNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household task created/updated/deleted/assigned/claimed/completed-by-other and rotation-turn-changed pushes.'
            householdApprovalNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household task approval requested/approved/declined/reopened/unassigned pushes.'
            householdRewardsGoalsNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household reward redemption requested/resolved and shared goal reached pushes.'
            householdMembershipNotificationsEnabled:
              type: boolean
              default: true
              description: 'Household invite sent/accepted, member removed, and ownership received/transferred pushes.'
            notificationProfile:
              type: string
              enum: [gentle, standard, persistent]
              default: standard
            notificationsCustomized:
              type: boolean
              default: false
            swipeRightAction:
              type: string
              enum: [complete, snooze, delete, schedule, multi_select, needs_clarification]
              default: complete
            swipeLeftAction:
              type: string
              enum: [complete, snooze, delete, schedule, multi_select, needs_clarification]
              default: delete
            mutedProjectIds:
              type: array
              items:
                type: string
              maxItems: 500
              description: 'QOL-D3: per-list notification muting — project ids for which reminders are suppressed.'
            privacyMode:
              type: string
              enum: [full, hide_on_lock_screen, hide_everywhere]
              default: full
              description: '#1754: lock-screen/outside-app content privacy.'
            habitStreakBrokenNotificationsEnabled:
              type: boolean
              default: false
              description: 'Off by default (opt-in, not opt-out).'
            habitStreakBrokenNotificationMode:
              type: string
              enum: [immediate, digest]
              default: digest
              description: "'immediate' fires only for a deliberate skip; 'digest' rolls up both skips and missed occurrences into one daily notification."
            habitStreakBrokenDigestHour:
              type: integer
              minimum: 0
              maximum: 23
              default: 9
        themeSync:
          type: [object, 'null']
          description: >-
            #2068: account-scoped theme sync -- resolved mode + primary/accent hex
            + optional web preset id, the common denominator between web's named
            presets and Android's raw custom colors. Whole-key last-write-wins,
            same semantics as notificationPreferences/themeSchedule.
          properties:
            mode:
              type: string
              enum: [light, dark, system]
            primaryColor:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
            accentColor:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
            presetId:
              type: [string, 'null']
              maxLength: 64
        navigationSettings:
          type: [object, 'null']
          description: >-
            #2191: web sidebar customization (hide/show + reorder), account-synced
            port of Android's NavItemRegistry order/hidden model. Section-key arrays;
            unknown/stale keys are reconciled client-side, not validated server-side.
          properties:
            order:
              type: array
              items:
                type: string
            hidden:
              type: array
              items:
                type: string
        diagnostics:
          type: object
          description: QOL-20 Android OEM Battery Kill Diagnostics
          properties:
            lastWorkerRun:
              oneOf:
                - type: string
                  format: date-time
                - type: 'null'
            lastWorkerDelay:
              oneOf:
                - type: integer
                - type: 'null'
              description: Last worker delay in milliseconds
            deviceManufacturer:
              oneOf:
                - type: string
                - type: 'null'
            batteryOptimizationState:
              oneOf:
                - type: string
                  enum: [unknown, optimized, unrestricted, restricted]
                - type: 'null'
        guidanceMode:
          type: string
          enum: [recommended, manual]
          default: recommended
          description: QOL-22 Guidance mode setting
        focusOverloadWarningEnabled:
          type: boolean
          default: true
          description: Show the Focus Overload warning when task count exceeds 7
        energyOverride:
          oneOf:
            - type: string
              enum: [low, medium, high]
            - type: 'null'
          description: '#615 one-tap energy override feeding focus scoring; null = inferred'
        e2eeEnabled:
          type: boolean
          default: false
          description: Whether E2EE is enabled for this user
        zapierWebhookUrl:
          oneOf:
            - type: string
              format: uri
            - type: 'null'
          description: User's Zapier webhook URL for outbound events
        zapierOutboundWebhookSecret:
          oneOf:
            - type: string
            - type: 'null'
          description: >-
            Device-synced HMAC signing secret for Android's outbound Zapier webhook
            dispatch (#1363). Distinct from zapierWebhookSecret, which is the inbound
            Zapier subscription secret and stays writeOnly.
        realtimeMode:
          type: string
          enum: [fcm, websocket, both, unifiedpush, up_ws]
          default: fcm
          description: Push notification transport — fcm (default), websocket (nogoogle), both, unifiedpush, or up_ws (UnifiedPush primary, WebSocket fallback — nogoogle auto)
        androidAutoEnabled:
          type: boolean
          default: false
          description: Whether Android Auto integration is enabled
        onboardingAndroidCompleted:
          type: boolean
          description: Whether the Android onboarding flow has been completed
        offlineModeEnabled:
          type: boolean
          description: Whether offline-only mode is enabled
        use24HourTime:
          oneOf:
            - type: boolean
            - type: 'null'
          description: Whether 24-hour time format is preferred (null/absent = match system locale)
        hideTaskRoulette:
          type: boolean
          default: false
          description: Hide Task Roulette entry points across clients
        defaultReminderMinutesBefore:
          oneOf:
            - type: integer
              minimum: -1
              maximum: 10080
            - type: 'null'
          default: 15
          description: >-
            Minutes before a task's due date+time to auto-attach an alarm at
            create time, for tasks created with no explicit reminder. -1 = the
            app's "no reminder" sentinel (explicitly off); null/absent = never
            configured, server normalizes to 15.
        hideTaskExamples:
          type: boolean
          default: false
          description: Hide the one-time starter-examples prompt for Tasks (#2236)
        hideHabitExamples:
          type: boolean
          default: false
          description: Hide the one-time starter-examples prompt for Habits (#2236)
        hideChoreExamples:
          type: boolean
          default: false
          description: Hide the one-time starter-examples prompt for Household Chores (#2236)
        hideNonDueHouseholdTasksToday:
          type: boolean
          default: false
          description: >-
            Hide other members' household tasks/chores from the household
            Today view unless they're due today or overdue. Per-user
            preference, not household-wide.
        externalTaskIntegrationsEnabled:
          type: boolean
          default: false
          description: >-
            Whether external task integrations (Android exported ContentProvider)
            are enabled. Fails closed: absent means false; clients push but never
            auto-enable from a pulled value.
        localFirstTasksEnabled:
          type: boolean
          default: false
          description: |
            Local-first task engine (#1668). Per-account opt-in, default off. When on,
            the client treats its local store as the source of truth and syncs through
            the ordered change log instead of the legacy delta path.
        showHabitsHeadsUp:
          type: boolean
          default: true
          description: Show the Today habits heads-up notice across clients
        calmListView:
          type: boolean
          default: false
          description: Calm list view — hide nudges, promos, and idle filter chips on Today across clients (#1733)
        defaultSort:
          type: string
          enum: [manual, dueDate, priority, created, alphabetical]
          description: Personal task-list sort mode (#1729). Persisted in the settings JSON blob.
        hideTodayFocusCard:
          type: boolean
          default: false
          description: Hide the Today Focus Session card (dismissed by the user), synced across clients
        hideTodayHabitsCard:
          type: boolean
          default: false
          description: Hide the Today Habits card (dismissed by the user), synced across clients
        cookieConsent:
          type: object
          description: Cookie consent preferences
        syncEnabled:
          type: boolean
          description: Legacy alias for autoSync
        statsEnabled:
          type: boolean
          description: Whether stats tracking is enabled
        collapseListsByDefault:
          type: boolean
          description: Whether lists are collapsed by default
        collapseTagsByDefault:
          type: boolean
          description: Whether tags are collapsed by default
        showListCounts:
          type: boolean
          description: Whether to show list item counts
        browseListFavIds:
          type: array
          items:
            oneOf:
              - type: string
              - type: 'null'
        browseTagFavIds:
          type: array
          items:
            type: string
        inboxShowBadgeCount:
          type: boolean
          description: Whether to show badge count on inbox
        inboxWeeklyReminderEnabled:
          type: boolean
        inboxWeeklyReminderWeekday:
          type: integer
          minimum: 0
          maximum: 6
        inboxAdaptiveEscalationThreshold:
          type: integer
          minimum: 1
          maximum: 1000
        inboxReviewSessionMode:
          type: boolean
        inboxReminderThreshold:
          type: integer
          minimum: 1
          maximum: 1000
        emailPriorityMappings:
          type: array
          items:
            type: object
        libraryLastProjectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        librarySavedWorkflows:
          type: array
          items:
            type: object
        wasmEnabled:
          type: boolean
        wasmKillSwitch:
          type: boolean
        clarityTelemetryEnabled:
          type: boolean
        clarityCoachingEnabled:
          type: boolean
          default: true
          description: Live coaching tips on task capture (on/off), synced account-wide. Independent of clarityTelemetryEnabled (analytics only).
        clarityThreshold:
          type: number
          minimum: 0
          maximum: 1
          default: 0.50
          description: Clarity score threshold below which a task is flagged needsClarification.
        clarityReviewEnabled:
          type: boolean
          default: true
          description: Clarity review digest enabled, synced account-wide.
        clarityReviewFrequency:
          type: string
          enum: [DAILY, EVERY_2_DAYS, WEEKLY, "OFF"]
          default: DAILY
          description: Frequency for clarity review digests.
        clarityReviewTime:
          type: string
          pattern: '^([01]\d|2[0-3]):[0-5]\d$'
          default: "20:00"
          description: Time of day (HH:mm) for clarity review digests.
        accessibilityHighContrast:
          type: boolean
          default: false
          description: High contrast mode for accessibility.
        accessibilityDyslexiaText:
          type: boolean
          default: false
          description: Dyslexia-friendly font mode for accessibility.
        accessibilityColorBlindness:
          type: string
          enum: [none, protanopia, deuteranopia, tritanopia]
          default: none
          description: Color blindness palette adjustment.
        accessibilityTouchTargetScale:
          type: string
          enum: [standard, large]
          default: standard
          description: Minimum touch target scaling.
        accessibilityCalmMode:
          type: boolean
          default: false
          description: Calm mode suppressing animations and sounds.
        accessibilityReduceAutoplay:
          type: boolean
          default: false
          description: Suppress automatic media and animation playback.
        accessibilitySimplifiedLanguage:
          type: boolean
          default: false
          description: Simplified language and microcopy.
        taskCardCompletionAnimation:
          type: string
          enum: [fade, strikethrough_fade, checkmark_bounce, none]
          default: strikethrough_fade
          description: Visual animation when completing a task card.
        taskCardOverdueStyle:
          type: string
          enum: [red_date_text, red_left_border, red_background_tint, none]
          default: red_date_text
          description: Visual indicator style for overdue task cards.
        taskCardPriorityIndicator:
          type: string
          enum: [colored_dot, colored_left_bar, colored_text, icon, none]
          default: colored_dot
          description: Visual priority indicator style on task cards.
        timerSkinId:
          type: string
          enum: [default, matcha, lofi, forest, tomato, sakura]
          default: default
          description: Selected visual skin for the web pomodoro timer.
        routinesCalendarEnabled:
          type: boolean
          default: true
          description: Routine calendar (#1641) — server-driven, GA default on; false hides the surface.
        defaultDueHour:
          type: integer
          minimum: 0
          maximum: 23
          default: 9
          description: Default hour (0-23) for new task due times. Roams across clients.
        defaultDueMinute:
          type: integer
          minimum: 0
          maximum: 59
          default: 0
          description: Default minute (0-59) for new task due times. Roams across clients.
        tabSortState:
          type: object
          additionalProperties:
            type: string
          description: Per-tab sort selection map. Roams across clients.
        recentSearches:
          type: array
          items:
            type: string
          description: Recently used search queries. Roams across clients.
        recentTags:
          type: array
          items:
            type: string
          description: Recently used tags. Roams across clients.

    UserSettingsUpdate:
      description: Partial update payload for user settings.
      allOf:
        - $ref: '#/components/schemas/UserSettingsBase'

    # Household E2EE (#1952): device approval, recovery, migration and child scope.
    E2eeChildAccessMode:
      type: string
      enum: [scoped_e2ee, pin_compatibility]
    E2eePinCompatibilityTask:
      type: object
      additionalProperties: false
      required: [title, completed, status, subtasks, comments, reward, approval, attachments]
      properties:
        title: { type: string, maxLength: 2000 }
        completed: { type: boolean }
        status:
          type: string
          enum: [pending, completed, pending_approval, approved, declined]
        subtasks:
          type: array
          maxItems: 500
          items:
            type: object
            additionalProperties: false
            required: [id, title, completed]
            properties:
              id: { type: string }
              title: { type: string, maxLength: 2000 }
              completed: { type: boolean }
        comments:
          type: array
          maxItems: 500
          items:
            type: object
            additionalProperties: false
            required: [id, authorMemberId, body, createdAt]
            properties:
              id: { type: string }
              authorMemberId: { type: string }
              body: { type: string, maxLength: 10000 }
              createdAt: { type: string, format: date-time }
        reward:
          oneOf:
            - type: object
              additionalProperties: false
              required: [id, title, points]
              properties:
                id: { type: string }
                title: { type: string }
                points: { type: integer }
            - type: 'null'
        approval:
          type: object
          additionalProperties: false
          required: [required, approved]
          properties:
            required: { type: boolean }
            approved: { type: [boolean, 'null'] }
        attachments:
          type: array
          maxItems: 100
          items:
            type: object
            additionalProperties: false
            required: [id, name, mimeType, size, explicitlyShared]
            properties:
              id: { type: string }
              name: { type: string }
              mimeType: { type: string }
              size: { type: integer, minimum: 0, maximum: 100000000 }
              explicitlyShared:
                type: boolean
                description: Must be true; unshared attachment metadata is never included in this lane.
    E2eePinCompatibilityEnvelope:
      type: object
      additionalProperties: false
      required: [version, task]
      properties:
        version: { type: integer, enum: [1] }
        task:
          $ref: '#/components/schemas/E2eePinCompatibilityTask'
    E2eeChildCompatibilityTask:
      type: object
      additionalProperties: false
      required: [entityId, payload]
      properties:
        entityId: { type: string }
        payload:
          $ref: '#/components/schemas/E2eePinCompatibilityEnvelope'
    E2eeDeviceKey:
      type: object
      required: [deviceId, publicKey, algorithm, keyEpoch, createdAt, pending, accessScope]
      properties:
        deviceId:
          type: string
        memberId:
          type: [string, 'null']
        publicKey:
          type: string
          description: Raw X25519 public key encoded as base64url.
        signingPublicKey:
          type: [string, 'null']
          description: Raw Ed25519 public key encoded as base64url.
        algorithm:
          type: string
          enum: [HPKE-X25519-HKDF-SHA256-AES256GCM]
        keyEpoch:
          type: integer
          minimum: 1
        wrappedKey:
          type: [string, 'null']
          description: Household key wrapped to this device with HPKE, encoded as base64url.
        deviceName:
          type: [string, 'null']
        approvedByDeviceId:
          type: [string, 'null']
        approvedAt:
          type: [string, 'null']
          format: date-time
        createdAt:
          type: string
          format: date-time
        revokedAt:
          type: [string, 'null']
          format: date-time
        pending:
          type: boolean
        accessScope:
          type: string
          description: Either `adult` or `child:<memberId>`.
        memberAccessMode:
          oneOf:
            - $ref: '#/components/schemas/E2eeChildAccessMode'
            - type: 'null'
    E2eeDeviceKeyRegister:
      type: object
      additionalProperties: false
      required: [deviceId, publicKey, signingPublicKey]
      properties:
        deviceId:
          type: string
        deviceName:
          type: string
        publicKey:
          type: string
        signingPublicKey:
          type: string
        wrappedKey:
          type: string
          description: Required only for the owner's first trusted device.
    E2eeStatus:
      type: object
      required: [householdId, state, migrationState, keyEpoch, recoveryConfigured, activeDeviceCount, myRole, children]
      properties:
        householdId:
          type: string
        state:
          type: string
          enum: ['off', full, partial]
        migrationState:
          type: string
          enum: ['off', preparing, encrypting, verifying, active, rotating, failed]
        keyEpoch:
          type: integer
        migrationStats:
          type: [object, 'null']
        recoveryConfigured:
          type: boolean
        activatedAt:
          type: [string, 'null']
          format: date-time
        plaintextDeletionDueAt:
          type: [string, 'null']
          format: date-time
        plaintextDeletedAt:
          type: [string, 'null']
          format: date-time
        activeDeviceCount:
          type: integer
        myRole:
          type: string
        myAccessMode:
          oneOf:
            - $ref: '#/components/schemas/E2eeChildAccessMode'
            - type: 'null'
        children:
          type: array
          items:
            type: object
            required: [id]
            properties:
              id:
                type: string
              displayName:
                type: [string, 'null']
              e2eeAccessMode:
                oneOf:
                  - $ref: '#/components/schemas/E2eeChildAccessMode'
                  - type: 'null'

    # Zapier integration schemas
    ZapierSubscription:
      type: object
      required: [id, targetUrl, eventTypes, enabled, createdAt]
      properties:
        id:
          type: string
        targetUrl:
          type: string
          format: uri
        eventTypes:
          type: array
          items:
            type: string
            enum: [task.created, task.updated, task.completed, task.deleted]
        enabled:
          type: boolean
        createdAt:
          type: string
          format: date-time

    ZapierSubscriptionCreate:
      type: object
      required: [targetUrl, eventTypes]
      properties:
        targetUrl:
          type: string
          format: uri
        eventTypes:
          type: array
          items:
            type: string
            enum: [task.created, task.updated, task.completed, task.deleted]

    ZapierDeliveryLog:
      type: object
      required: [id, eventType, status, attempts, createdAt]
      properties:
        id:
          type: string
        subscriptionId:
          type: string
        eventType:
          type: string
        status:
          type: string
          enum: [pending, delivered, failed, dead_letter]
        attempts:
          type: integer
        lastError:
          oneOf:
            - type: string
            - type: 'null'
        createdAt:
          type: string
          format: date-time
        dispatchedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'

    ZapierInboundTrigger:
      type: object
      required: [action, task]
      properties:
        action:
          type: string
          enum: [create_task, update_task]
        task:
          type: object
          required: [title]
          properties:
            title:
              type: string
            description:
              type: string
            priority:
              type: integer
              minimum: 0
              maximum: 4
            dueDate:
              type: string
              format: date-time
            tags:
              type: array
              items:
                type: string
            projectId:
              type: string
              format: uuid

    ZapierSecret:
      type: object
      required: [secret]
      properties:
        secret:
          type: string
          description: HMAC-SHA256 signing secret for inbound webhook verification
        inboundUrl:
          type: string
          format: uri
          description: The URL Zapier should POST to for inbound triggers

    WebhookSigningSecret:
      type: object
      required: [secret]
      properties:
        secret:
          type: string
          description: Per-account HMAC-SHA256 signing secret for outgoing webhook/socket events (#1863)
        message:
          type: string
          description: Present on rotate — confirms the previous secret was replaced

    AppPassword:
      type: object
      required: [id, name, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: My iOS app
        lastUsedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        createdAt:
          type: string
          format: date-time

    AppPasswordCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          example: My iOS app

    AppPasswordCreateResponse:
      allOf:
        - $ref: '#/components/schemas/AppPassword'
        - type: object
          required: [password]
          properties:
            password:
              type: string
              description: Plaintext app password — shown only once on creation

    PersonalAccessToken:
      type: object
      required: [id, name, scopes, failedAuthCount, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: Home Assistant
        scopes:
          type: array
          items:
            type: string
          example: ["tasks:read", "tasks:write"]
        lastUsedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        failedAuthCount:
          type: integer
          minimum: 0
          description: Number of failed authentication attempts with this token
        expiresAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        createdAt:
          type: string
          format: date-time

    PersonalAccessTokenCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          example: Home Assistant
        scopes:
          type: array
          items:
            type: string
          description: Requested scopes — defaults to ["tasks:read"] when omitted; unknown scopes are rejected (400 VALIDATION_ERROR)
          example: ["tasks:write"]
        expiresAt:
          type: string
          format: date-time
          description: Optional expiry — must be a future date when supplied, and no more than 365 days in the future (400 VALIDATION_ERROR otherwise)

    PersonalAccessTokenCreateResponse:
      allOf:
        - $ref: '#/components/schemas/PersonalAccessToken'
        - type: object
          required: [token]
          properties:
            token:
              type: string
              description: Plaintext Bearer token (taptidy_pat_<id>.<secret>) — shown only once on creation

    # -----------------------------------------------------------------------
    # Phase 3: Email settings schemas
    # -----------------------------------------------------------------------
    EmailSettings:
      type: object
      properties:
        emailProvider:
          type: string
          enum: [default, smtp, cloudmailin, forwardemail, brevo, postal]
        smtpHost:
          type: string
        smtpPort:
          type: integer
          example: 587
        smtpUser:
          type: string
        smtpFromEmail:
          type: string
          format: email
        smtpPasswordSet:
          type: boolean
          description: Whether an SMTP password has been configured (never returned in plaintext)
        lastSmtpError:
          oneOf:
            - type: string
            - type: 'null'
        lastSmtpErrorAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        emailAutoConvertActionable:
          type: boolean
          description: Automatically convert actionable inbound emails to tasks (default true)
        outboundProvider:
          type: string
          enum: [cloudmailin, smtp, forwardemail, brevo, postal]
          description: Outbound email provider alias (cloudmailin = default TapTidy relay)
        inboundDomain:
          type: string
          description: Selected inbound domain for computed handle address
          example: my.taptidy.app
        availableInboundDomains:
          type: array
          items:
            type: string
          example: [my.taptidy.app]

    EmailSettingsUpdate:
      type: object
      properties:
        emailProvider:
          type: string
          enum: [default, smtp, cloudmailin, forwardemail, brevo, postal]
        smtpHost:
          type: string
        smtpPort:
          type: integer
        smtpUser:
          type: string
        smtpPassword:
          type: string
          description: Write-only — never returned in GET responses
        smtpFromEmail:
          type: string
          format: email
        emailAutoConvertActionable:
          type: boolean
          description: Automatically convert actionable inbound emails to tasks
        outboundProvider:
          type: string
          enum: [cloudmailin, smtp, forwardemail, brevo, postal]
          description: Outbound email provider (cloudmailin = default TapTidy relay)
        inboundDomain:
          type: string
          description: Selected inbound domain; must be in availableInboundDomains

    EmailHandleSummary:
      type: object
      required: [id, handle, status, inboundAddress, createdAt]
      properties:
        id:
          type: string
          format: uuid
        handle:
          type: string
          minLength: 3
          maxLength: 30
          example: alice
        status:
          type: string
          enum: [PENDING, ACTIVE, DISABLED]
        inboundAddress:
          type: string
          example: alice@my.taptidy.app
        createdAt:
          type: string
          format: date-time

    EmailHandle:
      type: object
      # handle is null and id/status/inboundAddress/createdAt are absent when the
      # user has not claimed a handle (GET /me returns 200 in that case).
      required: [handle, availableDomains, selectedDomain]
      properties:
        id:
          type: string
          format: uuid
        handle:
          oneOf:
            - type: string
              minLength: 3
              maxLength: 30
              example: alice
            - type: 'null'
        status:
          type: string
          enum: [PENDING, ACTIVE, DISABLED]
          description: PENDING until email verified, ACTIVE once verified
        inboundAddress:
          type: string
          description: Full inbound email address (handle@domain)
          example: alice@my.taptidy.app
        createdAt:
          type: string
          format: date-time
        activeHandle:
          oneOf:
            - $ref: '#/components/schemas/EmailHandleSummary'
            - type: 'null'
        pendingHandle:
          oneOf:
            - $ref: '#/components/schemas/EmailHandleSummary'
            - type: 'null'
        availableDomains:
          type: array
          items:
            type: string
        selectedDomain:
          type: string
        verificationExpiresAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'

    EmailHandleResendResponse:
      type: object
      required: [status, nextAllowedAt, remainingInWindow, windowResetsAt]
      properties:
        status:
          type: string
          enum: [RESENT]
        nextAllowedAt:
          type: string
          format: date-time
        remainingInWindow:
          type: integer
        windowResetsAt:
          type: string
          format: date-time

    EmailHandleChangeRequest:
      type: object
      required: [newHandle]
      properties:
        newHandle:
          type: string
          minLength: 3
          maxLength: 30
          pattern: '^(?!.*\.\.)[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$'

    EmailTestRequest:
      type: object
      required: [to]
      properties:
        to:
          type: string
          format: email
          example: test@example.com

    EmailTestResponse:
      type: object
      required: [success]
      properties:
        success:
          type: boolean
        messageId:
          type: string
          description: SMTP message ID when delivery succeeded

    # -----------------------------------------------------------------------
    # Email webhook schemas — Forward Email
    # -----------------------------------------------------------------------
    ForwardEmailInboundPayload:
      type: object
      # Deliberately no `required`: identifyWebhookKind() accepts this shape on
      # `from` OR `subject`, and expressing that as an inner anyOf makes the
      # Kotlin generator emit garbage model names and drop the sub-models
      # (attachments, headerLines, from, to). The container-level anyOf keeps
      # the permissiveness harmless.
      description: Inbound email payload from Forward Email webhook (provider=forwardemail).
      properties:
        messageId:
          type: string
        from:
          oneOf:
            - type: string
            - type: object
              properties:
                address: { type: string }
                name: { type: string }
        to:
          oneOf:
            - type: string
            - type: array
              items:
                type: object
                properties:
                  address: { type: string }
                  name: { type: string }
        subject:
          type: string
        text:
          type: string
        html:
          type: string
        date:
          type: string
          format: date-time
        attachments:
          type: array
          items:
            type: object
            properties:
              filename: { type: string }
              contentType: { type: string }
              size: { type: integer }
              checksum: { type: string }
        headerLines:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              line: { type: string }
        tls:
          type: boolean
        remoteAddress:
          type: string
        spamScore:
          type: number

    ForwardEmailEventPayload:
      type: object
      # No `required: [event]`, deliberately. `event` IS the discriminator
      # identifyWebhookKind() keys on, but this schema is already published:
      # adding a required property to it narrows a pre-existing contract (and
      # narrows the generated DTO to a non-null field). The container-level
      # anyOf already makes the branch set satisfiable without it, so the
      # narrowing buys nothing. Only the new ForwardEmailBouncePayload, which
      # no consumer has seen before, carries a required discriminator.
      description: Delivery event payload from Forward Email webhook (provider=forwardemail).
      properties:
        event:
          type: string
          enum: [sent, delivered, bounced, complained, opened, clicked, unsubscribed]
        id:
          type: string
        messageId:
          type: string
        recipient:
          type: string
        date:
          type: string
          format: date-time
        timestamp:
          type: number

    ForwardEmailBouncePayload:
      type: object
      # `bounce` is the discriminator the handler keys on (hasBounceShape), and
      # it is what keeps this branch of the webhook oneOf from also matching an
      # inbound or delivery-event payload.
      required: [bounce]
      description: |
        Bounce/deferral payload from Forward Email webhook (provider=forwardemail).

        Distinct from ForwardEmailEventPayload: it carries no `event`
        discriminator, and the original message's headers are nested under
        `headers` rather than lifted to the top level. A `bounce.action` of
        `defer` marks a transient failure the provider will retry, normalized
        internally to the `deferred` event type rather than `bounced`.
      properties:
        email_id:
          type: string
          description: Forward Email's internal message id.
        recipient:
          type: string
        message:
          type: string
        response:
          type: string
        response_code:
          type: integer
          description: SMTP response code (4xx transient, 5xx permanent).
        truth_source:
          type: string
          description: Root domain of the trusted source of the response code, if any.
        bounce:
          type: object
          properties:
            action: { type: string, example: reject }
            message: { type: string }
            category:
              type: string
              description: One of block, recipient, spam, virus, capacity, network, etc.
              example: capacity
            code: { type: integer, example: 552 }
            status:
              # Union: Forward Email sends the DSN string, or the boolean false
              # when it could not parse one. Expressed as oneOf rather than a
              # 3.1 `type: [string, boolean]` list because openapi-generator's
              # normalizer rejects multi-type lists ("Type {} not yet supported
              # ... with multiple types") — see FIX 2k2 in
              # scripts/generate-kotlin-dtos.sh.
              oneOf:
                - type: string
                - type: boolean
              description: >-
                DSN code from the response message (e.g. "5.2.2"), or false when
                Forward Email could not parse one.
              example: "5.2.2"
            line: { type: integer }
        headers:
          type: object
          description: >-
            Headers of the original outbound message; Message-ID is used for
            correlation. A repeated header arrives as an array of values.
          additionalProperties:
            oneOf:
              - type: string
              - type: array
                items:
                  type: string
        bounced_at:
          type: string
          format: date-time
          description: ISO 8601 date for when the bounce error occurred.
        list_id:
          type: string
          description: List-ID header of the original outbound email, if any.
        list_unsubscribe:
          type: string
          description: List-Unsubscribe header of the original outbound email, if any.
        feedback_id:
          type: string
          description: Feedback-ID header of the original outbound email, if any.

    # -----------------------------------------------------------------------
    # Email webhook schemas — Brevo
    # -----------------------------------------------------------------------
    BrevoInboundPayload:
      type: object
      description: Inbound email payload from Brevo Parse API webhook (provider=brevo).
      properties:
        MessageId:
          type: string
        From:
          type: object
          properties:
            Name: { type: string }
            Address: { type: string }
        To:
          type: array
          items:
            type: object
            properties:
              Name: { type: string }
              Address: { type: string }
        Subject:
          type: string
        RawTextBody:
          type: string
        RawHtmlBody:
          type: string
        SentAtDate:
          type: string
          format: date-time
        SpamScore:
          type: number
        Attachments:
          type: array
          items:
            type: object
            properties:
              Name: { type: string }
              ContentType: { type: string }
              ContentLength: { type: integer }
              ContentID: { type: string }
              DownloadToken: { type: string }
        Headers:
          type: object
          additionalProperties:
            type: string

    BrevoEventPayload:
      type: object
      description: Delivery event payload from Brevo transactional webhook (provider=brevo).
      properties:
        event:
          type: string
          enum: [delivered, hard_bounce, soft_bounce, spam, click, open, unsubscribe, blocked, error, sent]
        message-id:
          type: string
        id:
          type: integer
        email:
          type: string
          description: Recipient email address
        date:
          type: string
        ts:
          type: integer
          description: Unix epoch timestamp
        ts_event:
          type: integer
        reason:
          type: string
        link:
          type: string

    # -----------------------------------------------------------------------
    # Email webhook schemas — Postal (self-hosted)
    # -----------------------------------------------------------------------
    PostalInboundPayload:
      type: object
      description: Inbound email payload from Postal webhook (provider=postal). Signed with RSA-SHA1 via X-Postal-Signature header.
      properties:
        id:
          type: integer
        rcpt_to:
          type: string
        mail_from:
          type: string
        message:
          type: string
          description: Base64-encoded raw MIME message
        size:
          type: integer
        spam_status:
          type: string
        spam_score:
          type: number
        tls:
          type: boolean
        subject:
          type: string
        message_id:
          type: string
        from:
          type: string
        to:
          type: array
          items:
            type: string
        plain_body:
          type: string
        html_body:
          type: string
        received_at:
          type: string
          format: date-time

    PostalEventPayload:
      type: object
      description: Delivery status event from Postal webhook (provider=postal).
      properties:
        status:
          type: string
          enum: [Sent, Delivered, HardFail, SoftFail, Bounced, Held]
        details:
          type: string
        output:
          type: string
        time:
          type: number
        sent_with_ssl:
          type: boolean
        message:
          type: object
          properties:
            id: { type: integer }
            token: { type: string }
            direction:
              type: string
              enum: [incoming, outgoing]
            message_id: { type: string }
            to: { type: string }
            from: { type: string }
            subject: { type: string }
            timestamp: { type: number }
            spam_status: { type: string }
            tag: { type: string }

    # -----------------------------------------------------------------------
    # Email webhook schemas (CloudMailin — deprecated, use provider-specific schemas above)
    # -----------------------------------------------------------------------
    CloudMailinInboundPayload:
      deprecated: true
      type: object
      description: |
        Payload from CloudMailin inbound routing webhook when an email arrives.
        Fields follow CloudMailin's JSON Normalized format.
        Deprecated: use ForwardEmailInboundPayload, BrevoInboundPayload, or PostalInboundPayload.
      properties:
        envelope:
          type: object
          description: SMTP envelope information
          properties:
            to:
              type: string
              description: Recipient from the SMTP envelope
              example: alice@my.taptidy.app
            from:
              type: string
              description: Sender from the SMTP envelope
              example: sender@example.com
            recipients:
              type: array
              items:
                type: string
              description: All recipients (when multiple)
            helo_domain:
              type: string
            remote_ip:
              type: string
            spf:
              type: object
              properties:
                result: { type: string }
                domain: { type: string }
        headers:
          type: object
          additionalProperties:
            oneOf:
              - type: string
              - type: array
                items:
                  type: string
          description: RFC822 headers (Message-ID, Subject, Date, etc.)
        plain:
          type: string
          description: Plain-text body
        html:
          type: string
          description: HTML body
        reply_plain:
          type: string
          description: Plain-text reply excerpt
        attachments:
          type: array
          items:
            type: object
            properties:
              file_name:
                type: string
              content_type:
                type: string
              size:
                type: integer
              disposition:
                type: string
              content_id:
                oneOf:
                  - type: string
                  - type: 'null'
              url:
                type: string

    CloudMailinEventPayload:
      deprecated: true
      type: object
      description: |
        Payload from CloudMailin outbound tracking webhook for email events.
        Deprecated: use ForwardEmailEventPayload, BrevoEventPayload, or PostalEventPayload.
      properties:
        event:
          type: string
          enum: [sent, delivered, delivery, bounce, bounced, hard_bounce, soft_bounce, complaint, complained, spam, unsubscribe, unsubscribed, open, opened, click, clicked]
          description: Event type
        message_id:
          type: string
          description: CloudMailin message identifier for correlation
        event_id:
          type: string
          description: CloudMailin event identifier
        recipient:
          type: string
          description: Recipient email address
        timestamp:
          oneOf:
            - type: string
            - type: number
          description: Unix epoch timestamp or ISO string
        code:
          type: integer
          description: SMTP response code (for bounces)
        reason:
          type: string
          description: Bounce reason

    # Legacy schemas (deprecated)
    # -----------------------------------------------------------------------
    ResendInboundPayload:
      type: object
      description: |
        Payload forwarded by Resend when an inbound email arrives. Fields follow
        Resend's inbound webhook schema. Only a safe subset of headers is stored.
      properties:
        from:
          type: string
          description: Sender address
          example: alice@example.com
        to:
          oneOf:
            - type: string
            - type: array
              items:
                type: string
          description: Recipient address(es) — Resend may send string or array
        subject:
          type: [string, 'null']
        text:
          type: [string, 'null']
          description: Plain-text body
        html:
          type: [string, 'null']
          description: HTML body
        messageId:
          type: [string, 'null']
          description: Message-ID header (Resend may send as messageId or message_id; server normalises both)
        headers:
          type: [object, 'null']
          additionalProperties:
            type: string
        attachments:
          type: [array, 'null']
          items:
            type: object
            properties:
              filename:
                type: string
              contentType:
                type: string
              size:
                type: integer

    TaptidyCapture:
      type: object
      description: An inbox capture item created from an inbound email or other source
      required: [id, userId, source, content, status, classification, createdAt]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        source:
          type: string
          enum: [EMAIL, UI, API]
        content:
          type: string
          description: Subject + first 500 chars of body for email captures
        status:
          type: string
          enum: [INBOX, CONVERTED, ARCHIVED]
        classification:
          $ref: '#/components/schemas/CaptureClassification'
        confidence:
          oneOf:
            - type: number
              format: float
              minimum: 0
              maximum: 1
            - type: 'null'
          description: Classifier confidence (0.0–1.0, 3 decimal precision)
        reasonsJson:
          oneOf:
            - type: string
            - type: 'null'
          description: JSON-encoded array of classifier reason codes
        sourceRefId:
          oneOf:
            - type: string
            - type: 'null'
          description: ID of source inbound email (when source=EMAIL)
        convertedTaskId:
          oneOf:
            - type: string
            - type: 'null'
        archivedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        reviewAfter:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        createdAt:
          type: string
          format: date-time
      x-used-internally: true  # referenced by EmailIngestWorker; kept for documentation

    # -----------------------------------------------------------------------
    # Phase 3: Push notification schemas
    # -----------------------------------------------------------------------
    VapidPublicKeyResponse:
      type: object
      required: [publicKey]
      properties:
        publicKey:
          type: string
          description: VAPID public key for Web Push subscription creation

    WebPushKeys:
      type: object
      required: [p256dh, auth]
      properties:
        p256dh:
          type: string
        auth:
          type: string

    WebPushSubscription:
      type: object
      required: [endpoint, keys]
      properties:
        endpoint:
          type: string
          format: uri
        keys:
          $ref: '#/components/schemas/WebPushKeys'

    PushSubscribeRequest:
      type: object
      required: [subscription]
      properties:
        subscription:
          $ref: '#/components/schemas/WebPushSubscription'

    PushUnsubscribeRequest:
      type: object
      required: [endpoint]
      properties:
        endpoint:
          type: string
          format: uri

    # -----------------------------------------------------------------------
    # Phase 3: Sync schemas
    # -----------------------------------------------------------------------
    OutboxProviderStatus:
      type: object
      required: [status, consecutiveFailures, deadLetterCount]
      properties:
        status:
          type: string
        lastCheckedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        consecutiveFailures:
          type: integer
        error:
          oneOf:
            - type: string
            - type: 'null'
        deadLetterCount:
          type: integer
          minimum: 0
          description: Outbox operations for this provider stuck in 'failed'/'dead' status (e.g. a task whose push permanently failed). Independent of `status` — provider connection can be 'healthy' while this is > 0.

    RetryDeadLettersResponse:
      type: object
      required: [success, retried]
      properties:
        success:
          type: boolean
        retried:
          type: integer

    DismissDeadLetterResponse:
      type: object
      required: [success]
      properties:
        success:
          type: boolean

    # #2170: itemized dead-letter access — one entry per 'failed'/'dead' outbox
    # operation owned by the caller. `error` is a stable classification of the
    # stored failure ('network' | 'http_<status>' | 'unknown'), never the raw
    # stored error text — provider responses and stack detail stay server-side.
    SyncDeadLetterOperation:
      type: object
      required: [id, providerId, entityType, entityId, operation, status, error, retryCount, payload, createdAt, updatedAt, deadAt]
      properties:
        id:
          type: string
        providerId:
          type: string
          description: Provider type the push targets (todoist | caldav | google_calendar).
        entityType:
          type: string
          description: "'task' | 'project' | 'tag'"
        entityId:
          type: string
        operation:
          type: string
          description: "'create' | 'update' | 'delete'"
        status:
          type: string
          enum: [failed, dead]
        error:
          oneOf:
            - type: string
            - type: 'null'
          description: Stable failure classification — 'network', 'http_<status>' (e.g. http_404), or 'unknown'. Raw stored error text is never exposed.
        retryCount:
          type: integer
          minimum: 0
        payload:
          # Free-form JSON entity snapshot. Enumerated JSON types (not
          # additionalProperties:true) so the kotlin generator emits a
          # serializable JsonElement, not an unserializable Map<String,Any> —
          # same pattern as SyncChange.payload.
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
            - type: array
          description: Entity snapshot the push was attempting, returned verbatim.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        deadAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'

    SyncDeadLetterListResponse:
      type: object
      required: [operations, truncated]
      properties:
        operations:
          type: array
          items:
            $ref: '#/components/schemas/SyncDeadLetterOperation'
        truncated:
          type: boolean
          description: True when the 100-row cap kicked in and more dead-lettered operations exist, so the client can distinguish "all clear" from "capped".

    OutboxSummary:
      type: object
      required: [pending, processing, completed, failed, total]
      properties:
        pending:
          type: integer
        processing:
          type: integer
        completed:
          type: integer
        failed:
          type: integer
        total:
          type: integer

    SyncStatusResponse:
      type: object
      required: [outbox]
      properties:
        lastRun:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        outbox:
          $ref: '#/components/schemas/OutboxSummary'
        providers:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/OutboxProviderStatus'
          description: Per-provider sync status keyed by provider ID

    SyncRunResponse:
      type: object
      required: [success, message]
      properties:
        success:
          type: boolean
        message:
          type: string
        syncedEntities:
          type: integer
        conflicts:
          type: integer
        errors:
          type: array
          items:
            type: string

    OutboxDrainResponse:
      type: object
      required: [success, message, processed, failed]
      properties:
        success:
          type: boolean
        message:
          type: string
        processed:
          type: integer
        failed:
          type: integer

    # -----------------------------------------------------------------------
    # Phase 3: Admin schemas
    # -----------------------------------------------------------------------
    AdminOutboxStats:
      type: object
      properties:
        pending:
          type: integer
        processing:
          type: integer
        completed:
          type: integer
        failed:
          type: integer
        deadLetter:
          type: integer
        oldestPendingAge:
          oneOf:
            - type: integer
              description: Age in milliseconds
            - type: 'null'
        retryHistogram:
          type: object
        byProvider:
          type: object
          additionalProperties:
            type: object
            properties:
              pending:
                type: integer
              failed:
                type: integer
              avgRetryCount:
                type: number
              totalRetries:
                type: integer
              count:
                type: integer

    ProviderHealth:
      type: object
      properties:
        id:
          type: string
        status:
          type: string
        lastCheckedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        consecutiveFailures:
          type: integer
        error:
          oneOf:
            - type: string
            - type: 'null'

    AdminSetting:
      type: object
      required: [key, value]
      properties:
        key:
          type: string
          example: signup_enabled
        value:
          description: Setting value (type varies by key)
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
            - type: array
        updatedBy:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        updatedAt:
          type: string
          format: date-time

    AdminSettingUpdateRequest:
      type: object
      required: [value]
      properties:
        value:
          description: New value for the setting
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
            - type: array

    # -----------------------------------------------------------------------
    # Phase 3: Sections and Filters schemas
    # -----------------------------------------------------------------------
    Section:
      type: object
      required: [id, name, order, projectId]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: Backlog
        order:
          type: integer
        projectId:
          type: string
          format: uuid
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    FilterExplainability:
      type: object
      required: [summary, factors]
      properties:
        summary:
          type: string
        factors:
          type: array
          items:
            type: string
        confidence:
          type: number
        fallbackUsed:
          type: boolean

    SmartListMeta:
      type: object
      required: [key, tier, behavior, generatedAt, staleAfterDays, minMatchCount]
      properties:
        key:
          type: string
          example: overdue
        tier:
          type: string
          enum: [free, pro]
        behavior:
          type: string
          enum: [static_rules, dynamic_materialized]
        generatedAt:
          type: string
          format: date-time
        staleAfterDays:
          type: integer
          example: 7
        minMatchCount:
          type: integer
          example: 3

    SavedFilter:
      type: object
      required: [id, userId, name, conditions, criteria, source, editable, deletable, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        name:
          type: string
          example: High priority today
        conditions:
          type: object
          description: Canonical filter conditions object
        criteria:
          type: object
          description: Temporary compatibility alias for conditions
        source:
          type: string
          enum: [user, smart]
        editable:
          type: boolean
        deletable:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        matchCount:
          oneOf:
            - type: integer
            - type: 'null'
        matchingTaskIds:
          oneOf:
            - type: array
              items:
                type: string
                format: uuid
            - type: 'null'
        lastEvaluatedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        explainability:
          oneOf:
            - $ref: '#/components/schemas/FilterExplainability'
            - type: 'null'
        smartList:
          oneOf:
            - $ref: '#/components/schemas/SmartListMeta'
            - type: 'null'

    # -----------------------------------------------------------------------
    # Phase 3: Timer schemas
    # -----------------------------------------------------------------------
    TimerSession:
      type: object
      required: [id, taskId, startedAt, duration, completed, type]
      properties:
        id:
          type: string
          format: uuid
        taskId:
          type: string
          format: uuid
        startedAt:
          type: string
          format: date-time
        completedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        duration:
          type: integer
          description: Session duration in seconds
          example: 1500
        completed:
          type: boolean
        type:
          type: string
          enum: [pomodoro, manual_log]
        difficulty:
          oneOf:
            - type: string
              enum: [easy, normal, hard]
            - type: 'null'
        pausedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        totalPausedSeconds:
          type: integer
          default: 0

    TimerLogRequest:
      type: object
      required: [durationMinutes]
      properties:
        durationMinutes:
          type: integer
          minimum: 1
          example: 25
        difficulty:
          type: string
          enum: [easy, normal, hard]

    TimerSessionActionRequest:
      type: object
      required: [sessionId]
      properties:
        sessionId:
          type: string
          format: uuid

    # -----------------------------------------------------------------------
    # Phase 3: Journal schemas
    # -----------------------------------------------------------------------
    JournalEntry:
      type: object
      required: [id, content, createdAt]
      properties:
        id:
          type: string
          format: uuid
        content:
          type: string
          example: Made solid progress on the API today.
        createdAt:
          type: string
          format: date-time

    JournalCreateRequest:
      type: object
      required: [content]
      properties:
        content:
          type: string
          minLength: 1
          example: Made solid progress on the API today.

    # -----------------------------------------------------------------------
    # Task Comment schemas
    # -----------------------------------------------------------------------
    TaskComment:
      type: object
      required: [id, userId, body, createdAt]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        body:
          type: string
          example: Blocked on the API response shape — pinging @sam.
        createdAt:
          type: string
          format: date-time

    TaskCommentCreateRequest:
      type: object
      required: [body]
      properties:
        body:
          type: string
          minLength: 1
          maxLength: 10000
          example: Blocked on the API response shape — pinging @sam.

    TaskCommentList:
      type: object
      required: [comments]
      properties:
        comments:
          type: array
          items:
            $ref: '#/components/schemas/TaskComment'

    # -----------------------------------------------------------------------
    # Phase 3: Activity schemas
    # -----------------------------------------------------------------------
    TaskActivityEntry:
      type: object
      required: [id, action, timestamp]
      properties:
        id:
          type: string
          format: uuid
        action:
          type: string
          example: completed
        details:
          type: object
        timestamp:
          type: string
          format: date-time

    GlobalActivityEntry:
      type: object
      required: [id, entityType, entityId, action, timestamp]
      properties:
        id:
          type: string
          format: uuid
        entityType:
          type: string
          enum: [task, project, tag]
        entityId:
          type: string
          format: uuid
        action:
          type: string
          example: created
        details:
          type: object
        timestamp:
          type: string
          format: date-time

    # -----------------------------------------------------------------------
    # Phase 3: Quick capture, Import, Backup schemas
    # -----------------------------------------------------------------------
    QuickCaptureRequest:
      type: object
      required: [title]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
          example: Send follow-up email
        projectId:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        tags:
          type: array
          items:
            type: string
          default: []
        timeSpentMinutes:
          type: integer
          minimum: 1
          description: If provided, creates a retroactive manual timer session

    ImportOptions:
      type: object
      properties:
        importCompleted:
          type: boolean
          default: false
          description: Whether to import completed tasks
        mergeStrategy:
          type: string
          enum: [skip, overwrite]
          default: skip
          description: How to handle duplicate tasks

    ImportRequest:
      type: object
      required: [format]
      properties:
        format:
          type: string
          enum: [todoist_api, csv, markdown, todoist_csv, json]
          description: Import source format
        apiToken:
          type: string
          description: Todoist personal API token
        content:
          type: string
          description: Raw file content (CSV, Markdown, JSON, or Todoist CSV text)
        mappings:
          type: object
          required: [title]
          properties:
            title:
              type: string
            description:
              type: string
            dueDate:
              type: string
            priority:
              type: string
            tags:
              type: string
            project:
              type: string
            completed:
              type: string
        options:
          type: object
          properties:
            mergeStrategy:
              type: string
              enum: [skip, overwrite]
              default: skip
            importCompleted:
              type: boolean
              default: false

    ImportRequestTodoistApi:
      type: object
      required: [format, apiToken]
      properties:
        format:
          type: string
          enum: [todoist_api]
        apiToken:
          type: string
          description: Todoist personal API token
        options:
          type: object
          properties:
            mergeStrategy:
              type: string
              enum: [skip, overwrite]
              default: skip
            importCompleted:
              type: boolean
              default: false

    ImportRequestCsv:
      type: object
      required: [format, content, mappings]
      properties:
        format:
          type: string
          enum: [csv]
        content:
          type: string
          description: Raw CSV text
        mappings:
          type: object
          required: [title]
          properties:
            title:
              type: string
            description:
              type: string
            dueDate:
              type: string
            priority:
              type: string
            tags:
              type: string
            project:
              type: string
            completed:
              type: string

    ImportRequestMarkdown:
      type: object
      required: [format, content]
      properties:
        format:
          type: string
          enum: [markdown]
        content:
          type: string
          description: Raw Markdown checklist text

    ImportRequestTodoistCsv:
      type: object
      required: [format, content]
      properties:
        format:
          type: string
          enum: [todoist_csv]
        content:
          type: string
          description: Raw Todoist CSV export text

    ImportRequestJson:
      type: object
      required: [format, content]
      properties:
        format:
          type: string
          enum: [json]
        content:
          type: string
          description: JSON-stringified TapTidy backup

    ImportResult:
      type: object
      required: [successCount, errorCount, projectsCreated, tagsCreated, errors]
      properties:
        successCount:
          type: integer
        errorCount:
          type: integer
        projectsCreated:
          type: integer
        tagsCreated:
          type: integer
        errors:
          type: array
          items:
            type: object
            properties:
              row:
                type: integer
              message:
                type: string

    ImportResponse:
      type: object
      required: [success, result]
      properties:
        success:
          type: boolean
        result:
          $ref: '#/components/schemas/ImportResult'

    ExportRequest:
      type: object
      required: [format]
      properties:
        format:
          type: string
          enum: [csv, markdown, json, passport]
        filter:
          type: object
          properties:
            projectIds:
              type: array
              items:
                type: string
                format: uuid
            includeCompleted:
              type: boolean
              default: true
            dateRange:
              type: object
              properties:
                from:
                  type: string
                  format: date-time
                to:
                  type: string
                  format: date-time

    BackupData:
      type: object
      properties:
        tasks:
          type: array
          items:
            $ref: '#/components/schemas/TaskResponse'
        projects:
          type: array
          items:
            type: object
        tags:
          type: array
          items:
            type: object

    TaskPassportData:
      type: object
      required: [tasks, projects, tags, routines, reminders]
      properties:
        tasks:
          type: array
          items:
            type: object
        projects:
          type: array
          items:
            type: object
        tags:
          type: array
          items:
            type: object
        routines:
          type: array
          items:
            type: object
          description: Reserved for future routine export
        reminders:
          type: array
          items:
            type: object
          description: Reserved for future reminder export

    BackupExportResponse:
      type: object
      required: [exportedAt, version, userId, data]
      properties:
        exportedAt:
          type: string
          format: date-time
        version:
          type: string
          example: "1.0"
        userId:
          type: string
          format: uuid
        data:
          $ref: '#/components/schemas/BackupData'

    TaskPassport:
      type: object
      required: [version, exportedAt, userId, kind, manifest, data, privacy, readme]
      properties:
        version:
          type: string
          example: "1.1"
        exportedAt:
          type: string
          format: date-time
        userId:
          type: string
          format: uuid
        kind:
          type: string
          example: "taptidy-task-passport"
        manifest:
          type: object
          required: [taskCount, projectCount, tagCount, completedCount, routineCount, reminderCount, receiptCount, caldavConnected, appPasswordCount, sha256]
          properties:
            taskCount:
              type: integer
              format: int32
              minimum: 0
            projectCount:
              type: integer
              format: int32
              minimum: 0
            tagCount:
              type: integer
              format: int32
              minimum: 0
            completedCount:
              type: integer
              format: int32
              minimum: 0
            routineCount:
              type: integer
              format: int32
              minimum: 0
            reminderCount:
              type: integer
              format: int32
              minimum: 0
            receiptCount:
              type: integer
              format: int32
              minimum: 0
            caldavConnected:
              type: boolean
            appPasswordCount:
              type: integer
              format: int32
              minimum: 0
            sha256:
              type: string
        data:
          $ref: '#/components/schemas/TaskPassportData'
        privacy:
          type: object
          required: [includesAiMetadata, includesShareKeys, includesAppPasswords, includesPushTokens]
          properties:
            includesAiMetadata:
              type: boolean
              example: false
            includesShareKeys:
              type: boolean
              example: false
            includesAppPasswords:
              type: boolean
              example: false
            includesPushTokens:
              type: boolean
              example: false
        readme:
          type: string
          description: Human-readable explanation of what is included and what is not.

    SafetyScoreResponse:
      type: object
      required: [timestamp, userId, total, bucket, topReason, recommendedAction, components]
      properties:
        timestamp:
          type: string
          format: date-time
        userId:
          type: string
          format: uuid
        total:
          type: integer
          minimum: 0
          maximum: 100
        bucket:
          type: string
          enum: [EXCELLENT, GOOD, NEEDS_ATTENTION, CRITICAL]
        topReason:
          type: string
        recommendedAction:
          type: string
        components:
          type: object
          required: [syncScore, backupScore, conflictScore, exportScore, privacyScore, reminderScore]
          properties:
            syncScore:
              type: integer
              minimum: 0
              maximum: 100
            backupScore:
              type: integer
              minimum: 0
              maximum: 100
            conflictScore:
              type: integer
              minimum: 0
              maximum: 100
            exportScore:
              type: integer
              minimum: 0
              maximum: 100
            privacyScore:
              type: integer
              minimum: 0
              maximum: 100
            reminderScore:
              type: integer
              minimum: 0
              maximum: 100

    SovereigntyResponse:
      type: object
      required: [timestamp, userId, householdId, settingsConfigured, telemetry, ai, encryption, allPrivate]
      properties:
        timestamp:
          type: string
          format: date-time
        userId:
          type: string
          format: uuid
        householdId:
          type: string
        settingsConfigured:
          type: boolean
        telemetry:
          type: object
          required: [sessionLogging, clarityTelemetry, wasm]
          properties:
            sessionLogging:
              type: boolean
            clarityTelemetry:
              type: boolean
            wasm:
              type: boolean
        ai:
          type: object
          required: [provider, onDeviceOnly]
          properties:
            provider:
              type: string
              example: on_device
            onDeviceOnly:
              type: boolean
        encryption:
          type: object
          required: [e2eeEnabled, syncE2EE, state, compatibilityChildCount, compatibilityRowCount, activePlaintextCanonicalTasks]
          properties:
            e2eeEnabled:
              type: boolean
            syncE2EE:
              type: boolean
            state:
              type: string
              enum: ['off', full, partial]
            compatibilityChildCount:
              type: integer
              minimum: 0
            compatibilityRowCount:
              type: integer
              minimum: 0
            activePlaintextCanonicalTasks:
              type: integer
              minimum: 0
              maximum: 1
            plaintextDeletedAt:
              type: [string, 'null']
              format: date-time
            historicalBackupDeletionDueAt:
              type: [string, 'null']
              format: date-time
        allPrivate:
          type: boolean

    BackupImportRequest:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/BackupData'

    BackupImportResponse:
      type: object
      required: [success, result]
      properties:
        success:
          type: boolean
        result:
          $ref: '#/components/schemas/ImportResult'
        _deprecated_imported:
          description: Deprecated — use result instead
          type: object
          properties:
            tasks:
              type: integer
            projects:
              type: integer
            tags:
              type: integer

    FocusTopRecommendationsRequest:
      type: object
      required: [context]
      properties:
        candidateTaskIds:
          type: array
          items: { type: string, format: uuid }
        context:
          type: object
          required: [timeOfDay]
          properties:
            timeOfDay:
              type: string
              enum: [morning, midday, afternoon, evening, night]
            availableMinutes:
              type: integer
              minimum: 1
            excludeRecentlyFocused:
              type: boolean
            preferHighPriority:
              type: boolean
            cognitiveLoadPreference:
              type: string
              enum: [low, medium, high]
        limit:
          type: integer
          minimum: 1
          maximum: 10
          default: 3

    FocusTopRecommendation:
      type: object
      required: [rank, recommendedTaskId, taskTitle, score, rationale, factors, duration, explain]
      properties:
        rank: { type: integer, minimum: 1 }
        recommendedTaskId: { type: string, format: uuid }
        taskTitle: { type: string }
        score: { type: number }
        rationale: { type: string }
        factors:
          type: array
          items:
            type: object
            additionalProperties: true
        duration:
          type: object
          additionalProperties: true
        explain:
          type: object
          additionalProperties: true

    FocusTopRecommendationsMetadata:
      type: object
      required: [generatedAt, algorithmVersion, timeOfDay, candidateCount, returnedCount, requestedLimit, processingTimeMs]
      properties:
        generatedAt: { type: string, format: date-time }
        algorithmVersion: { type: string }
        timeOfDay:
          type: string
          enum: [morning, midday, afternoon, evening, night]
        candidateCount: { type: integer, minimum: 0 }
        returnedCount: { type: integer, minimum: 0 }
        requestedLimit: { type: integer, minimum: 1 }
        processingTimeMs: { type: integer, minimum: 0 }

    FocusTopRecommendationsResponse:
      type: object
      required: [recommendations, metadata]
      properties:
        recommendations:
          type: array
          items:
            $ref: '#/components/schemas/FocusTopRecommendation'
        metadata:
          $ref: '#/components/schemas/FocusTopRecommendationsMetadata'

    FocusTop3ServedRequest:
      type: object
      required: [source, items]
      properties:
        source:
          type: string
          enum: [focus, fallback, cache]
        servedAt:
          type: string
          format: date-time
        items:
          type: array
          minItems: 1
          maxItems: 3
          items:
            type: object
            required: [taskId]
            properties:
              taskId: { type: string, format: uuid }
              rank:
                type: integer
                minimum: 1
                maximum: 3
                description: Optional client hint; server derives authoritative rank from item order.

    FocusTop3ServedResponse:
      type: object
      required: [recorded]
      properties:
        recorded:
          type: integer
          minimum: 0

    FocusTop3CompletionRequest:
      type: object
      required: [taskId]
      properties:
        taskId: { type: string, format: uuid }

    FocusTop3CompletionResponse:
      type: object
      required: [attributed]
      properties:
        attributed: { type: boolean }
        reason:
          type: string
          enum:
            - feature_disabled
            - task_not_found
            - task_not_completed
            - task_completed_outside_window
            - no_recent_served
            - attribution_already_consumed
        source:
          type: string
          enum: [focus, fallback, cache]
        rank: { type: integer, minimum: 1, maximum: 3 }
        hoursSinceServed: { type: number, minimum: 0 }

    # =====================================================================
    # AI schemas
    # =====================================================================
    TaskBreakdownRequest:
      type: object
      required: [taskTitle]
      properties:
        taskTitle:
          type: string
          minLength: 1
        context:
          type: string
        provider:
          type: string
          enum: [on_device, gemini, openai, anthropic, qwen, groq, perplexity]
        maxSuggestions:
          type: integer
          minimum: 1
          maximum: 10

    TaskBreakdownResponse:
      type: object
      required: [suggestions, provider, latencyMs]
      properties:
        suggestions:
          type: array
          items:
            type: object
            required: [title, confidence]
            properties:
              title:
                type: string
              estimatedMinutes:
                type: integer
              confidence:
                type: number
                minimum: 0
                maximum: 1
        provider:
          type: string
          enum: [on_device, gemini, openai, anthropic, qwen, groq, perplexity]
        latencyMs:
          type: integer

    AiTelemetryEvent:
      type: object
      required: [eventType, feature, provider, fallback]
      properties:
        eventType:
          type: string
          enum: [suggestion_shown, suggestion_accepted, suggestion_dismissed, fallback_triggered, error]
        feature:
          type: string
          enum: [smart_list, task_breakdown, nudge_rewriting]
        provider:
          type: string
          enum: [on_device, gemini, openai, anthropic, qwen, groq, perplexity]
        latencyMs:
          type: integer
        confidence:
          type: number
          minimum: 0
          maximum: 1
        fallback:
          type: boolean
        userAction:
          type: string
          enum: [accepted, dismissed, ignored]
        errorMessage:
          type: string

    # =====================================================================
    # Analytics events + A/B experiments (#1463)
    # =====================================================================
    AnalyticsEvent:
      type: object
      required: [name]
      description: Product analytics event (consent-gated, best-effort).
      properties:
        name:
          type: string
          description: Lowercase snake_case event name, e.g. task_completed, view_switched, ai_feedback.
          pattern: '^[a-z0-9_]+$'
          maxLength: 64
        props:
          type: object
          description: Small PII-free structured payload.
          additionalProperties: true
        source:
          type: string
          enum: [web, android, ios]
          default: web

    ExperimentVariant:
      type: object
      required: [key, weight]
      properties:
        key:
          type: string
          maxLength: 64
        weight:
          type: number
          exclusiveMinimum: 0
          description: Relative bucketing weight (positive).

    Experiment:
      type: object
      required: [id, key, name, status, variants]
      properties:
        id:
          type: string
        key:
          type: string
        name:
          type: string
        description:
          type: [string, 'null']
        status:
          type: string
          enum: [draft, running, paused, completed]
        variants:
          type: array
          items:
            $ref: '#/components/schemas/ExperimentVariant'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    ExperimentAssignmentsResponse:
      type: object
      required: [assignments]
      properties:
        assignments:
          type: object
          description: Map of experiment key -> assigned variant key for running experiments.
          additionalProperties:
            type: string

    ExperimentCreateRequest:
      type: object
      required: [key, name, variants]
      properties:
        key:
          type: string
          pattern: '^[a-z0-9_]+$'
          maxLength: 64
        name:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 2000
        status:
          type: string
          enum: [draft, running, paused, completed]
        variants:
          type: array
          minItems: 2
          maxItems: 10
          items:
            $ref: '#/components/schemas/ExperimentVariant'

    ExperimentUpdateRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 200
        description:
          type: string
          maxLength: 2000
        status:
          type: string
          enum: [draft, running, paused, completed]
        variants:
          type: array
          minItems: 2
          maxItems: 10
          items:
            $ref: '#/components/schemas/ExperimentVariant'

    # =====================================================================
    # Shopping schemas
    # =====================================================================
    ShoppingItemsResponse:
      type: object
      required: [projectId, projectName, isShoppingList, categories, totalItems, completedItems]
      properties:
        projectId:
          type: string
        projectName:
          type: string
        isShoppingList:
          type: boolean
        categories:
          type: object
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                id: { type: string }
                title: { type: string }
                completed: { type: boolean }
                quantity: { type: [string, 'null'] }
                category: { type: string }
                createdAt: { type: string, format: date-time }
        totalItems:
          type: integer
        completedItems:
          type: integer

    ShoppingBatchResponse:
      type: object
      properties:
        success:
          type: boolean
        created:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              title: { type: string }
              category: { type: string }

    ShoppingResetResponse:
      type: object
      properties:
        success:
          type: boolean
        action:
          type: string
          enum: [cleared, unchecked]
        deletedCount:
          type: integer
        updatedCount:
          type: integer

    ShoppingCollaboratorsResponse:
      type: object
      properties:
        collaborators:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              user:
                type: object
                properties:
                  id: { type: string }
                  email: { type: string }
                  name: { type: string }
              role:
                type: string
                enum: [viewer, editor]
              sharedBy:
                type: object
                properties:
                  id: { type: string }
                  email: { type: string }
                  name: { type: string }
              sharedAt: { type: string, format: date-time }

    # =====================================================================
    # Notes schemas
    # =====================================================================
    NoteCreate:
      type: object
      required: [title]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
        content:
          type: string
          maxLength: 50000
        tags:
          type: array
          maxItems: 20
          items:
            type: string
            maxLength: 50
        projectId:
          type: [string, 'null']
          format: uuid

    NoteUpdate:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 500
        content:
          type: string
          maxLength: 50000
        tags:
          type: array
          maxItems: 20
          items:
            type: string
            maxLength: 50
        projectId:
          type: [string, 'null']
          format: uuid

    NoteResponse:
      type: object
      required: [id, title, createdAt, updatedAt]
      properties:
        id: { type: string }
        title: { type: string }
        content: { type: string }
        tags:
          type: array
          items: { type: string }
        projectId: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    NoteListResponse:
      type: object
      required: [notes, total, page, pageSize, hasMore]
      properties:
        notes:
          type: array
          items:
            $ref: '#/components/schemas/NoteResponse'
        total: { type: integer }
        page: { type: integer }
        pageSize: { type: integer }
        hasMore: { type: boolean }

    # =====================================================================
    # Library / Category schemas
    # =====================================================================
    CategoryInfo:
      type: object
      required: [id, name, icon, color, order, count, builtIn]
      properties:
        id: { type: string }
        name: { type: string }
        icon: { type: string }
        color: { type: string }
        order: { type: integer }
        count: { type: integer }
        builtIn: { type: boolean }

    CategoryCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        icon:
          type: string
          maxLength: 10
          default: ''
        color:
          type: string
          maxLength: 20
          default: slate

    CategoryUpdate:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        icon:
          type: string
          maxLength: 10
        color:
          type: string
          maxLength: 20
        order:
          type: integer
          minimum: 0

    # =====================================================================
    # Routines schemas
    # =====================================================================
    RoutineTemplate:
      type: object
      required: [id, title, scheduleType, createdAt, updatedAt]
      properties:
        id: { type: string }
        title: { type: string }
        description: { type: [string, 'null'] }
        scheduleType:
          type: string
          enum: [fixed, dynamic, flexible]
        recurrenceRule: { type: [string, 'null'] }
        dynamicIntervalHours: { type: [number, 'null'] }
        frequencyGoalCount: { type: [integer, 'null'] }
        frequencyGoalPeriod:
          type: [string, 'null']
          enum: [week, month, null]
        routineStyle:
          type: string
          enum: [log-only, task-generating]
        taskTemplateId: { type: [string, 'null'] }
        targetMinutes: { type: [number, 'null'] }
        difficultyMode:
          type: string
          enum: [gentle, balanced, intense]
        isActive: { type: boolean }
        order: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        stats:
          type: object
          properties:
            streakCurrent: { type: integer }
            streakBest: { type: integer }
            totalOccurrences: { type: integer }
            totalCompletions: { type: integer }
            completionRate: { type: number }

    RoutineTemplateCreate:
      type: object
      required: [title, scheduleType]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        description:
          type: string
          maxLength: 1000
        scheduleType:
          type: string
          enum: [fixed, dynamic, flexible]
        recurrenceRule: { type: string }
        dynamicIntervalHours:
          type: number
          minimum: 1
          maximum: 168
        frequencyGoalCount:
          type: integer
          minimum: 1
          maximum: 31
        frequencyGoalPeriod:
          type: string
          enum: [week, month]
        routineStyle:
          type: string
          enum: [log-only, task-generating]
        taskTemplateId:
          type: [string, 'null']
          format: uuid
        targetMinutes:
          type: number
          minimum: 1
          maximum: 480
        difficultyMode:
          type: string
          enum: [gentle, balanced, intense]

    RoutineTemplateUpdate:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        description:
          type: string
          maxLength: 1000
        scheduleType:
          type: string
          enum: [fixed, dynamic, flexible]
        recurrenceRule: { type: string }
        dynamicIntervalHours:
          type: number
          minimum: 1
          maximum: 168
        frequencyGoalCount:
          type: integer
          minimum: 1
          maximum: 31
        frequencyGoalPeriod:
          type: string
          enum: [week, month]
        routineStyle:
          type: string
          enum: [log-only, task-generating]
        taskTemplateId:
          type: [string, 'null']
          format: uuid
        targetMinutes:
          type: number
          minimum: 1
          maximum: 480
        difficultyMode:
          type: string
          enum: [gentle, balanced, intense]
        isActive: { type: boolean }

    RoutineOccurrence:
      type: object
      required: [id, date, recurrenceId]
      properties:
        id: { type: string }
        date: { type: string, format: date-time }
        recurrenceId: { type: string }
        isException: { type: boolean }
        event:
          type: [object, 'null']
          properties:
            id: { type: string }
            action: { type: string }
            minutesLogged: { type: number }
            notes: { type: [string, 'null'] }
            createdAt: { type: string, format: date-time }

    RecurrencePreviewResponse:
      type: object
      required: [recurrenceRule, startDate, endDate, total, occurrences]
      properties:
        recurrenceRule: { type: string }
        startDate: { type: string, format: date-time }
        endDate: { type: string, format: date-time }
        total: { type: integer }
        occurrences:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date-time }
              recurrenceId: { type: string }
              isException: { type: boolean }

    RoutinesTodayResponse:
      type: object
      required: [date, occurrences, summary]
      properties:
        date: { type: string, format: date-time }
        occurrences:
          type: array
          items:
            $ref: '#/components/schemas/RoutineOccurrence'
        summary:
          type: object
          properties:
            totalOccurrences: { type: integer }
            completed: { type: integer }
            skipped: { type: integer }
            pending: { type: integer }
            totalMinutes: { type: number }

    RoutineStatsResponse:
      type: object
      required: [period, summary, templates]
      properties:
        period:
          type: object
          properties:
            start: { type: string, format: date-time }
            end: { type: string, format: date-time }
        summary:
          type: object
          properties:
            totalTemplates: { type: integer }
            totalOccurrences: { type: integer }
            completed: { type: integer }
            skipped: { type: integer }
            missed: { type: integer }
            adherencePercent: { type: number }
            completionPercent: { type: number }
            totalMinutes: { type: number }
            totalTargetMinutes: { type: number }
            targetAchievementPercent: { type: number }
            streakCurrent: { type: integer }
            streakBest: { type: integer }
        templates:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              title: { type: string }
              targetMinutes: { type: number }
              streakCurrent: { type: integer }
              streakBest: { type: integer }
              adherencePercent: { type: number }

    RoutineTimelineResponse:
      type: object
      required: [period, events, pagination]
      properties:
        period:
          type: object
          properties:
            start: { type: string, format: date-time }
            end: { type: string, format: date-time }
        events:
          type: array
          items:
            $ref: '#/components/schemas/RoutineTimelineEvent'
        byDate:
          type: object
          additionalProperties:
            type: array
            items:
              $ref: '#/components/schemas/RoutineTimelineEvent'
        pagination:
          type: object
          properties:
            hasMore: { type: boolean }
            nextCursor: { type: string, format: date-time }
            limit: { type: integer }

    RoutineTimelineEvent:
      type: object
      properties:
        id: { type: string }
        templateId: { type: string }
        templateTitle: { type: string }
        action: { type: string }
        minutesLogged: { type: number }
        notes: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }

    RoutineLogResponse:
      type: object
      properties:
        event:
          $ref: '#/components/schemas/RoutineTimelineEvent'
        periodProgress:
          type: object
          properties:
            completed: { type: integer }
            goal: { type: integer }
            periodEnd: { type: string, format: date-time }

    RoutineOccurrenceActionResponse:
      type: object
      properties:
        occurrenceId: { type: string }
        event:
          $ref: '#/components/schemas/RoutineTimelineEvent'
        task:
          type: [object, 'null']

    # =====================================================================
    # Nudge schema
    # =====================================================================
    Nudge:
      type: object
      required: [id, type, title, body]
      properties:
        id: { type: string }
        type: { type: string }
        title: { type: string }
        body: { type: string }
        actionUrl: { type: string }
        metadata: { type: object }

    # =====================================================================
    # Journal list schema
    # =====================================================================
    JournalListResponse:
      type: object
      required: [entries, total, page, pageSize, hasMore]
      properties:
        entries:
          type: array
          items:
            type: object
            required: [id, content, createdAt, taskId]
            properties:
              id: { type: string }
              content: { type: string }
              createdAt: { type: string, format: date-time }
              taskId: { type: string }
              taskTitle: { type: [string, 'null'] }
        total: { type: integer }
        page: { type: integer }
        pageSize: { type: integer }
        hasMore: { type: boolean }

    # =====================================================================
    # Contact schema
    # =====================================================================
    ContactRequest:
      type: object
      required: [name, email, subject, message]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        email:
          type: string
          format: email
          maxLength: 320
        subject:
          type: string
          enum: [General, Bug Report, Feature Request, Privacy / Data, Account Issue]
        message:
          type: string
          minLength: 10
          maxLength: 5000

    # =====================================================================
    # Release schema
    # =====================================================================
    ReleaseInfo:
      type: object
      properties:
        tag_name: { type: string }
        name: { type: string }
        published_at: { type: string, format: date-time }
        html_url: { type: string }
        assets:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              browser_download_url: { type: string }
              size: { type: integer }

    AndroidAbiVariant:
      type: object
      required: [abi, apkUrl, sha256]
      properties:
        # x86_64 is reserved: the build pipeline (splits.abi) only produces arm64-v8a and
        # armeabi-v7a today; an x86_64 device falls back to the universal APK. It stays in the
        # enum for a future Play-AAB / emulator distribution channel.
        abi: { type: string, enum: [arm64-v8a, armeabi-v7a, x86_64] }
        apkUrl: { type: string, format: uri }
        sha256:
          type: string
          pattern: "^[a-fA-F0-9]{64}$"
          description: "64-char hex SHA-256 digest of the per-ABI APK"

    AndroidVersionInfo:
      type: object
      required: [flavor, versionCode, versionName, tag, apkUrl, sha256]
      properties:
        flavor: { type: string, enum: [google, nogoogle] }
        versionCode: { type: integer }
        versionName: { type: string }
        tag: { type: string }
        apkUrl: { type: string, format: uri }
        sha256:
          type: string
          pattern: "^[a-fA-F0-9]{64}$"
          description: "64-char hex SHA-256 digest of the APK"
        variants:
          type: array
          minItems: 1
          maxItems: 8
          description: "Optional per-ABI APKs (PERF-B-07). Clients pick the entry matching the device ABI and fall back to apkUrl/sha256 (the universal APK) when absent."
          items: { $ref: '#/components/schemas/AndroidAbiVariant' }

    PublishAndroidVersionRequest:
      type: object
      required: [flavor, versionCode, versionName, tag, apkUrl, sha256]
      properties:
        flavor: { type: string, enum: [google, nogoogle] }
        versionCode: { type: integer }
        versionName: { type: string }
        tag: { type: string }
        apkUrl: { type: string, format: uri }
        sha256:
          type: string
          pattern: "^[a-fA-F0-9]{64}$"
        variants:
          type: array
          minItems: 1
          maxItems: 8
          items: { $ref: '#/components/schemas/AndroidAbiVariant' }

    # =====================================================================
    # Email Analytics schema
    # =====================================================================
    EmailAnalyticsResponse:
      type: object
      required: [timeRange, outbound, events, metrics]
      properties:
        timeRange:
          type: object
          properties:
            days: { type: integer }
            startDate: { type: string, format: date-time }
            endDate: { type: string, format: date-time }
        outbound:
          type: object
          properties:
            total: { type: integer }
            byStatus:
              type: object
              additionalProperties: { type: integer }
        events:
          type: object
          properties:
            total: { type: integer }
            byType:
              type: object
              additionalProperties: { type: integer }
        metrics:
          type: object
          properties:
            deliveryRate: { type: number }
            bounceRate: { type: number }
            openRate: { type: number }
        recentBounces:
          type: array
          items:
            type: object
            properties:
              recipient: { type: string }
              occurredAt: { type: string, format: date-time }
              providerMessageId: { type: string }
        recentComplaints:
          type: array
          items:
            type: object
            properties:
              recipient: { type: string }
              occurredAt: { type: string, format: date-time }
              providerMessageId: { type: string }

    # ── Bulk Archive schemas (Issue #852) ─────────────────────────────────

    BulkArchiveCriteria:
      type: object
      properties:
        olderThanDays:
          type: integer
          minimum: 1
          description: Only include tasks not updated in the last N days
        statuses:
          type: array
          items:
            type: string
            enum: [todo, in_progress, overdue]
        includeCompleted:
          type: boolean
          default: false
        completedOnly:
          type: boolean
          description: >-
            Restrict the operation to completed tasks only. Takes precedence
            over includeCompleted/statuses. Used by "Delete all completed tasks".
        projectIds:
          type: array
          items:
            type: string
            format: uuid
        tags:
          type: array
          items:
            type: string

    BulkArchivePreviewResponse:
      type: object
      required: [wouldArchive, sampleTasks, breakdown]
      properties:
        wouldArchive:
          type: integer
        sampleTasks:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              title: { type: string }
              dueDate: { type: [string, "null"], format: date-time }
              status: { type: [string, "null"] }
        breakdown:
          type: object
          required: [overdue, stale, completed]
          properties:
            overdue: { type: integer }
            stale: { type: integer }
            completed: { type: integer }
        oldestTaskDate:
          type: [string, "null"]
          format: date-time

    BulkArchiveResult:
      type: object
      required: [operationId, archivedCount]
      properties:
        operationId:
          type: string
          format: uuid
        archivedCount:
          type: integer

    ArchiveOperation:
      type: object
      required: [id, archivedAt, taskCount, operationType, canUndo]
      properties:
        id:
          type: string
          format: uuid
        archivedAt:
          type: string
          format: date-time
        taskCount:
          type: integer
        criteria:
          type: object
          additionalProperties:
            type: string
        reason:
          type: [string, "null"]
        operationType:
          type: string
          enum: [archive, delete]
        canUndo:
          type: boolean

    FreshStartRequest:
      type: object
      properties:
        keepRecurring:
          type: boolean
          default: true
        keepShared:
          type: boolean
          default: true
        reason:
          type: string
          maxLength: 200

    FreshStartResult:
      type: object
      required: [operationId, archivedCount, preservedCount]
      properties:
        operationId:
          type: string
          format: uuid
        archivedCount:
          type: integer
        preservedCount:
          type: integer

    # Productivity Features
    DailyCapacity:
      type: object
      required: [id, userId, enabled, taskLimit, tasksCompleted, currentDate, streakDays, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        enabled:
          type: boolean
        taskLimit:
          type: integer
          minimum: 1
        tasksCompleted:
          type: integer
          minimum: 0
        reachedAt:
          oneOf:
            - type: string
              format: date-time
            - type: 'null'
        currentDate:
          type: string
          format: date
          example: "2026-04-21"
        streakDays:
          type: integer
          minimum: 0
        updatedAt:
          type: string
          format: date-time

    TaskDependency:
      type: object
      required: [id, taskId, blockedById, createdAt]
      properties:
        id:
          type: string
          format: uuid
        taskId:
          type: string
          format: uuid
        blockedById:
          type: string
          format: uuid
        createdAt:
          type: string
          format: date-time

    RouletteSpin:
      type: object
      required: [id, userId, taskId, spunAt, completed]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        taskId:
          type: string
          format: uuid
        spunAt:
          type: string
          format: date-time
        completed:
          type: boolean

    LocalProductivityStats:
      type: object
      required: [peakHour, peakHourLabel, driftRisk, completionVelocity, routineAdherence, streakBest, narrative, suggestions]
      properties:
        peakHour:
          type: integer
          minimum: 0
          maximum: 23
        peakHourLabel:
          type: string
          example: "9–11 AM"
        driftRisk:
          type: string
          enum: [low, medium, high]
        completionVelocity:
          type: number
          minimum: 0
          description: Tasks completed per week over the last 12 weeks
        routineAdherence:
          type: number
          minimum: 0
          maximum: 1
          description: Ratio of completed routine occurrences in the last 30 days
        streakBest:
          type: integer
          minimum: 0
        narrative:
          type: string
          description: Human-readable summary of productivity patterns
        suggestions:
          type: array
          items:
            type: string

    HouseholdIconKey:
      type: string
      description: '#2085 Settings hub: fixed preset icon set for a household.'
      enum: [home, family, heart, star, tree, sun, sparkles, building]
      default: home

    HouseholdSummary:
      type: object
      required: [id, name, myRole, memberCount]
      properties:
        id:
          type: string
        name:
          type:
            - string
            - 'null'
        icon:
          $ref: '#/components/schemas/HouseholdIconKey'
        timezone:
          type: string
          default: UTC
        economyEnabled:
          type: boolean
          default: false
        fairnessReportEnabled:
          type: boolean
          default: false
        fairnessReportHidden:
          type: boolean
          default: false
          description: >-
            Owner-hidable fairness card, independent of fairnessReportEnabled.
            Resurfaced via Household Options.
        syncProtocol:
          $ref: '#/components/schemas/SyncProtocol'
        notifyOverrideQuietHours:
          type: boolean
          default: false
          description: >-
            Household-wide override that bypasses each recipient's quiet-hours
            window for chore reminder pushes. Off by default.
        assignmentPosture:
          $ref: '#/components/schemas/AssignmentPosture'
        childrenSeeErrandPool:
          type: boolean
          default: false
          description: >-
            Owner opt-in: unassigned/unclaimed errands become visible
            (view-only — children cannot self-claim) to child members. Off by
            default.
        errandGeofenceEnabled:
          type: boolean
          default: false
          description: >-
            #2223 item2: owner opt-in gating the errand-geofence UI (per-user
            F5 geofence rules extended to target a household errand). Off by
            default; an errand's place stays optional even when on.
        pointsRestrictedToOwners:
          type: boolean
          default: false
          description: >-
            Owner/co-owner-only creation rule. When true, task/chore creation
            by a non-owner/co-owner member always lands at 0 points,
            regardless of what the creator submits. Off by default.
        approvalRequiredForMembers:
          type: boolean
          default: false
          description: >-
            Owner/co-owner-only creation rule. When true, task/chore creation
            by a non-owner/co-owner member always requires approval,
            regardless of what the creator submits. Off by default.
        settingsVersion:
          type: integer
          minimum: 0
          default: 0
          description: >-
            #2166 optimistic-concurrency counter over the household settings
            surface. Incremented by every settings write; clients pass it back
            as baseVersion to detect interleaved writes.
        myRole:
          type: string
          enum: [owner, co-owner, member]
        memberCount:
          type: integer
          minimum: 0

    HouseholdMemberUser:
      type: object
      required: [id, email]
      properties:
        id:
          type: string
        name:
          type:
            - string
            - 'null'
        email:
          type: string

    HouseholdMember:
      type: object
      required: [id, householdId, role, accountType, createdAt]
      properties:
        id:
          type: string
        householdId:
          type: string
        userId:
          type:
            - string
            - 'null'
        role:
          type: string
          enum: [owner, co-owner, member]
        accountType:
          type: string
          enum: [adult, child]
        displayName:
          type:
            - string
            - 'null'
        dateOfBirth:
          type:
            - string
            - 'null'
          format: date-time
        loginCode:
          type:
            - string
            - 'null'
        parentConsentedAt:
          type:
            - string
            - 'null'
          format: date-time
        consentedByUserId:
          type:
            - string
            - 'null'
        capabilities:
          oneOf:
            - $ref: '#/components/schemas/MemberCapabilities'
            - type: 'null'
        vacationRanges:
          oneOf:
            - type: array
              items:
                $ref: '#/components/schemas/VacationRange'
            - type: 'null'
        choreParticipation:
          type: boolean
          description: Owner-set eligibility for chore assignment on this managed profile
        rewardsEligible:
          type: boolean
          description: Owner-set eligibility for rewards on this managed profile
        e2eeAccessMode:
          oneOf:
            - $ref: '#/components/schemas/E2eeChildAccessMode'
            - type: 'null'
          description: Owner-selected E2EE behavior for a child member; null until configured.
        createdAt:
          type: string
          format: date-time
        user:
          oneOf:
            - $ref: '#/components/schemas/HouseholdMemberUser'
            - type: 'null'

    MemberCapabilities:
      type: object
      required: [canCreateTasks, canCompleteWithoutApproval, allowedProjectIds]
      properties:
        canCreateTasks:
          type: boolean
        canCompleteWithoutApproval:
          type: boolean
        allowedProjectIds:
          oneOf:
            - type: array
              items:
                type: string
            - type: 'null'

    VacationRange:
      type: object
      required: [start, end]
      properties:
        start:
          type: string
          format: date
          example: '2026-08-01'
        end:
          type: string
          format: date
          example: '2026-08-10'

    MemberVacationRanges:
      type: object
      properties:
        vacationRanges:
          oneOf:
            - type: array
              items:
                $ref: '#/components/schemas/VacationRange'
            - type: 'null'

    MemberEligibility:
      type: object
      properties:
        choreParticipation:
          type: boolean
        rewardsEligible:
          type: boolean

    RotationConfig:
      type: object
      required: [fairnessWeighting, skipOnVacation, holidayDates]
      properties:
        fairnessWeighting:
          type: string
          enum: [equal, effort]
        skipOnVacation:
          type: boolean
        holidayDates:
          type: array
          items:
            type: string
            format: date

    GeocodeResult:
      type: object
      required: [lat, lon, display_name]
      properties:
        lat:
          type: string
        lon:
          type: string
        display_name:
          type: string

    GeocodeSearchResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/GeocodeResult'

    # Partial update body for PATCH /household/rotation-config. All fields optional:
    # the server merges each supplied field onto the current config, so a client sends
    # only what it changes instead of read-modify-writing the whole object (which risks
    # clobbering a concurrent writer's fields). Responses still use RotationConfig.
    RotationConfigPatch:
      type: object
      properties:
        fairnessWeighting:
          type: string
          enum: [equal, effort]
        skipOnVacation:
          type: boolean
        holidayDates:
          type: array
          items:
            type: string
            format: date
        #2166 optimistic-concurrency guard: when sent, the write 409s unless it
        # matches the server's current settingsVersion. Omitted = last-write-wins.
        baseVersion:
          type: integer
          minimum: 0

    # #2166: full household settings snapshot, returned in the 409
    # SETTINGS_VERSION_CONFLICT body so the caller can rebase without a
    # follow-up GET. Mirrors the settings surface of HouseholdSummary.
    HouseholdSettingsSnapshot:
      type: object
      required: [name, icon, timezone, notifyOverrideQuietHours, economyEnabled, fairnessReportEnabled, fairnessReportHidden, rotationConfig, syncProtocol, settingsVersion, assignmentPosture, childrenSeeErrandPool, errandGeofenceEnabled, pointsRestrictedToOwners, approvalRequiredForMembers]
      properties:
        name:
          type: string
        icon:
          $ref: '#/components/schemas/HouseholdIconKey'
        timezone:
          type: string
        notifyOverrideQuietHours:
          type: boolean
        economyEnabled:
          type: boolean
        fairnessReportEnabled:
          type: boolean
        fairnessReportHidden:
          type: boolean
          description: Owner-hidable fairness card, independent of fairnessReportEnabled. Resurfaced via Household Options.
        rotationConfig:
          $ref: '#/components/schemas/RotationConfig'
        syncProtocol:
          $ref: '#/components/schemas/SyncProtocol'
        settingsVersion:
          type: integer
          minimum: 0
        assignmentPosture:
          $ref: '#/components/schemas/AssignmentPosture'
        childrenSeeErrandPool:
          type: boolean
        errandGeofenceEnabled:
          type: boolean
        pointsRestrictedToOwners:
          type: boolean
        approvalRequiredForMembers:
          type: boolean

    # 409 body for POST /api/v1/tasks/{id}/restore's If-Match/expectedVersion
    # guard. currentVersion/expectedVersion are nullable — expectedVersion is
    # absent when the caller sent none (still a genuine conflict, e.g. a
    # concurrent delete raced this restore), and currentVersion is absent for
    # a legacy row with no syncVersion recorded. res.json() drops `undefined`
    # keys entirely, so the server always sends `null` rather than omitting
    # the key, to satisfy this schema's `required` list.
    TaskRestoreVersionConflict:
      type: object
      required: [code, message, currentVersion, expectedVersion]
      properties:
        code:
          type: string
          enum: [CONFLICT]
        message:
          type: string
        currentVersion:
          oneOf:
            - type: integer
              minimum: 0
            - type: 'null'
        expectedVersion:
          oneOf:
            - type: integer
              minimum: 0
            - type: 'null'

    # 409 body for every household settings write that carries baseVersion.
    HouseholdSettingsVersionConflict:
      type: object
      required: [code, message, currentVersion, settings]
      properties:
        code:
          type: string
          enum: [SETTINGS_VERSION_CONFLICT]
        message:
          type: string
        currentVersion:
          type: integer
          minimum: 0
        settings:
          oneOf:
            - $ref: '#/components/schemas/HouseholdSettingsSnapshot'
            - type: 'null'

    # #1668 Step 9: household sync-protocol cutover. 'changelog' (default) =
    # household task writes flow through the ordered change log (complete/reopen
    # verbs, invariant-carrying applier); 'merge' = legacy REST writes, kept as
    # an explicit owner opt-out.
    SyncProtocol:
      type: string
      enum: [merge, changelog]
      default: changelog

    # #2225/#2223 work-distribution posture. 'pool' allows POST
    # /household/tasks/{id}/claim to claim an unassigned errand; 'ownerAssigns'
    # keeps the legacy owner-assign-only behavior. New households default to
    # 'pool'; households that existed before this field shipped were backfilled
    # to 'ownerAssigns' so existing behavior is unchanged for them.
    AssignmentPosture:
      type: string
      enum: [pool, ownerAssigns]
      default: pool

    # Full sync-protocol state, returned by both GET and PATCH so clients need
    # no follow-up read after a flip.
    HouseholdSyncProtocolState:
      type: object
      required: [syncProtocol, changelogAvailable, myRole, settingsVersion]
      properties:
        syncProtocol:
          $ref: '#/components/schemas/SyncProtocol'
        changelogAvailable:
          type: boolean
          description: |
            Whether this server's change-log endpoints are enabled.
            Clients should only render the cutover toggle when true.
        myRole:
          type: string
          enum: [owner, co-owner, member]
          description: Caller's role — only owner-tier callers (owner or co-owner) may PATCH.
        settingsVersion:
          type: integer
          minimum: 0
          description: '#2166 household settings version — send as baseVersion on the next settings write.'

    HouseholdTask:
      type: object
      required: [id, title, completed, status, createdAt, updatedAt]
      properties:
        id:
          type: string
        title:
          type: string
        description:
          type:
            - string
            - 'null'
        priority:
          type:
            - integer
            - 'null'
        completed:
          type: boolean
        completedAt:
          type:
            - string
            - 'null'
          format: date-time
        status:
          type: string
        archivedAt:
          type:
            - string
            - 'null'
          format: date-time
        dueDate:
          type:
            - string
            - 'null'
          format: date-time
        dueDateHasTime:
          type: boolean
          description: Whether dueDate carries an explicit time component (false for all-day tasks)
        dueDateRaw:
          type:
            - string
            - 'null'
          pattern: '^\d{4}-\d{2}-\d{2}$'
          description: Due date as YYYY-MM-DD string (for all-day tasks)
        snoozeUntil:
          type:
            - string
            - 'null'
          format: date-time
          description: Snooze task until this timestamp
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        userId:
          type:
            - string
            - 'null'
        householdId:
          type:
            - string
            - 'null'
        householdTaskKind:
          type:
            - string
            - 'null'
          enum: [shared, errand]
        householdCreatedBy:
          type:
            - string
            - 'null'
        routineOccurrenceId:
          type:
            - string
            - 'null'
          description: ID of the routine occurrence that generated this task (null for hand-made tasks)
        requiresApproval:
          type: boolean
        recurrence:
          description: Structured recurrence rule, legacy string form, or null when not recurring
          oneOf:
            - $ref: '#/components/schemas/RecurrenceRule'
            - type: string
            - type: 'null'
        completedByChild:
          type: boolean
          description: >-
            True when the task's chore assignee is a child account; the backend
            treats it as child-completed and only a household owner may reopen it.
        completedByMemberId:
          type:
            - string
            - 'null'
          description: >-
            The household member who actually completed this occurrence (may
            differ from the current assignee if reassigned after completion).
            Null until completed.
        effortWeight:
          type: integer
          minimum: 1
          maximum: 999
          default: 1
          description: Chore economy point value credited on completion/approval
        taskTier:
          type: string
          enum: [baseline, hustle]
          default: baseline
          description: '#1722 baseline chores earn 0 points; hustle chores credit effortWeight.'
        projectId:
          type:
            - string
            - 'null'
          description: >-
            #2223 item3: the linked shopping-list project, when set (errands
            only). Clients resolve the display name from their own project list.
        alarms:
          type: array
          items:
            $ref: '#/components/schemas/Alarm'
          maxItems: 20
          default: []
          description: '#2292 reminder alarms, editable after creation.'
        pinCompatibility:
          oneOf:
            - $ref: '#/components/schemas/E2eePinCompatibilityTask'
            - type: 'null'
          description: Reduced server-readable task view returned only to the directly assigned PIN child in a partial-E2EE household.
        creditedDelta:
          type:
            - integer
            - 'null'
          description: >-
            Points actually credited by this response's action (currently
            only POST /complete and POST /approve populate it). Null on
            every other response, and null when this action credited
            nothing (economy off, baseline task, approval still pending,
            idempotent replay).
        needsClarification:
          type: boolean
          description: >-
            Flag indicating this task needs clarification before it can be
            acted on. Server-scored; always false for a child creator or
            when the account disabled clarity coaching.
          example: false
        clarityScore:
          oneOf:
            - type: number
              format: float
              minimum: 0
              maximum: 1
            - type: 'null'
          description: >-
            The clarity score behind needsClarification. Null when never
            scored (child creator, coaching disabled, or a due date is set).

    HouseholdAssignmentAssignee:
      type: object
      required: [id]
      properties:
        id:
          type: string
        name:
          type:
            - string
            - 'null'
        email:
          type:
            - string
            - 'null'

    HouseholdChoreAssignment:
      type: object
      required: [id, householdId, taskId, assigneeId, rotation, assignedAt, source, createdAt]
      properties:
        id:
          type: string
        householdId:
          type: string
        taskId:
          type: string
        assigneeId:
          type: string
        rotation:
          type: boolean
        assignedByUserId:
          type:
            - string
            - 'null'
        assignedByMemberId:
          type:
            - string
            - 'null'
        assignedAt:
          type: string
          format: date-time
        source:
          type: string
        rotationCycle:
          type:
            - integer
            - 'null'
        createdAt:
          type: string
          format: date-time
        assignee:
          oneOf:
            - $ref: '#/components/schemas/HouseholdAssignmentAssignee'
            - type: 'null'
        # #2224 item3: preview of who rotation would hand this chore to next.
        # Non-null only when rotation=true; null for non-rotating assignments.
        nextAssigneeId:
          type:
            - string
            - 'null'
        nextAssigneeName:
          type:
            - string
            - 'null'

    HouseholdRotationEventAssignee:
      type: object
      required: [id]
      properties:
        id:
          type: string
        name:
          type:
            - string
            - 'null'
        email:
          type:
            - string
            - 'null'

    HouseholdRotationEvent:
      type: object
      required: [id, householdId, kind, toAssigneeId, createdByUserId, rotationCycle, source, createdAt]
      properties:
        id:
          type: string
        householdId:
          type: string
        kind:
          type: string
          enum: [task, template]
          description: '#1916 task rotations set taskId; template rotations set templateId.'
        taskId:
          type:
            - string
            - 'null'
        templateId:
          type:
            - string
            - 'null'
        fromAssigneeId:
          type:
            - string
            - 'null'
        toAssigneeId:
          type: string
        createdByUserId:
          type: string
        createdByMemberId:
          type:
            - string
            - 'null'
        rotationCycle:
          type: integer
        source:
          type: string
        reason:
          type:
            - string
            - 'null'
        createdAt:
          type: string
          format: date-time
        fromAssignee:
          oneOf:
            - $ref: '#/components/schemas/HouseholdRotationEventAssignee'
            - type: 'null'
        toAssignee:
          oneOf:
            - $ref: '#/components/schemas/HouseholdRotationEventAssignee'
            - type: 'null'

    HouseholdRoutine:
      type: object
      required: [task, assignment]
      properties:
        task:
          $ref: '#/components/schemas/HouseholdTask'
        assignment:
          oneOf:
            - $ref: '#/components/schemas/HouseholdChoreAssignment'
            - type: 'null'

    HouseholdInvite:
      type: object
      required: [id, householdId, maskedEmail, isLinkInvite, role, invitedByUserId, status, expiresAt, createdAt]
      properties:
        id:
          type: string
        householdId:
          type: string
        maskedEmail:
          type:
            - string
            - 'null'
        isLinkInvite:
          type: boolean
          description: 'true for a shareable-link invite with no recipient email.'
        normalizedEmail:
          type: string
        role:
          type: string
        invitedByUserId:
          type: string
        invitedUserId:
          type:
            - string
            - 'null'
        status:
          type: string
        expiresAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time
        acceptedAt:
          type:
            - string
            - 'null'
          format: date-time
        revokedAt:
          type:
            - string
            - 'null'
          format: date-time

    HouseholdMemberWeeklyLoad:
      type: object
      required: [memberId, load]
      properties:
        memberId:
          type: string
        name:
          type:
            - string
            - 'null'
        load:
          type: integer
          minimum: 0
        completedLoad:
          type: integer
          minimum: 0
          description: '#1725 effort completed this week (ledger positive deltas).'

    HouseholdCounters:
      type: object
      required: [openShared, openErrands, overdue, pendingInvites, activeRoutines, memberWeeklyLoad]
      properties:
        openShared:
          type: integer
          minimum: 0
        openErrands:
          type: integer
          minimum: 0
        overdue:
          type: integer
          minimum: 0
        pendingInvites:
          type: integer
          minimum: 0
        activeRoutines:
          type: integer
          minimum: 0
        memberWeeklyLoad:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdMemberWeeklyLoad'

    HouseholdData:
      type: object
      required: [household, householdId, members, tasks, chores, invites, routines, rotationEvents, counters]
      properties:
        household:
          $ref: '#/components/schemas/HouseholdSummary'
        householdId:
          type: string
        members:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdMember'
        tasks:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdTask'
        chores:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdChoreAssignment'
        invites:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdInvite'
        routines:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdRoutine'
        rotationEvents:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdRotationEvent'
        counters:
          $ref: '#/components/schemas/HouseholdCounters'

    HouseholdEconomyBalance:
      type: object
      required: [memberId, displayName, accountType, balance, weekEarned]
      properties:
        memberId:
          type: string
        displayName:
          type: string
        accountType:
          type: string
          enum: [adult, child]
        balance:
          type: integer
        weekEarned:
          type: integer

    HouseholdEconomyBalancesResponse:
      type: object
      required: [balances]
      properties:
        balances:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdEconomyBalance'

    HouseholdPointLedgerEntry:
      type: object
      required: [id, memberId, delta, reason, createdAt]
      properties:
        id:
          type: string
        memberId:
          type: string
        delta:
          type: integer
        reason:
          type: string
          enum: [chore_completed, chore_approved, chore_reversed, redeemed, manual_adjust]
        taskId:
          type:
            - string
            - 'null'
        redemptionId:
          type:
            - string
            - 'null'
        createdAt:
          type: string
          format: date-time

    HouseholdEconomyLedgerResponse:
      type: object
      required: [memberId, entries]
      properties:
        memberId:
          type: string
        entries:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdPointLedgerEntry'

    HouseholdReward:
      type: object
      # isSharedGoal/currentFunding/reusable are always populated by the server but kept out
      # of `required` so adding them is a non-breaking additive response change
      # (the OpenAPI breaking-change gate flags newly-required properties).
      required: [id, householdId, title, pointCost, active, createdAt]
      properties:
        id:
          type: string
        householdId:
          type: string
        title:
          type: string
        pointCost:
          type: integer
          minimum: 1
        active:
          type: boolean
        isSharedGoal:
          type: boolean
          description: '#1724 When true, redemptions pool as contributions toward pointCost.'
        reusable:
          type: boolean
          default: false
          description: 'When true, the reward stays active after an approved redemption. One-shot rewards (default) auto-deactivate after their first approved redemption. Always false for shared goals.'
        currentFunding:
          type: integer
          description: '#1724 Sum of approved contributions for a shared goal; 0 for standard rewards.'
        createdAt:
          type: string
          format: date-time

    HouseholdRewardsResponse:
      type: object
      required: [rewards]
      properties:
        rewards:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdReward'

    HouseholdEconomyStarterPackRecurrence:
      type: object
      required: [frequency]
      properties:
        frequency:
          type: string
          enum: [daily, weekly]
        interval:
          type: integer
          minimum: 1
        byDay:
          type: array
          items:
            type: string

    HouseholdEconomyStarterPackChore:
      type: object
      required: [key, title, taskTier, effortWeight, requiresApproval, rotation, recurrence]
      properties:
        key:
          type: string
        title:
          type: string
        taskTier:
          type: string
          enum: [baseline, hustle]
        effortWeight:
          type: integer
          minimum: 0
          maximum: 3
        requiresApproval:
          type: boolean
        rotation:
          type: boolean
        recurrence:
          $ref: '#/components/schemas/HouseholdEconomyStarterPackRecurrence'

    HouseholdEconomyStarterPackReward:
      type: object
      required: [key, title, pointCost, isSharedGoal]
      properties:
        key:
          type: string
        title:
          type: string
        pointCost:
          type: integer
          minimum: 1
        isSharedGoal:
          type: boolean

    HouseholdEconomyStarterPack:
      type: object
      required: [id, title, description, freeTierCompatible, requiresChildMember, alreadyApplied, chores, rewards]
      properties:
        id:
          type: string
          enum: [solo-reward-board, kids-chore-chart, roommate-split]
        title:
          type: string
        description:
          type: string
        freeTierCompatible:
          type: boolean
        requiresChildMember:
          type: boolean
        alreadyApplied:
          type: boolean
        chores:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdEconomyStarterPackChore'
        rewards:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdEconomyStarterPackReward'

    HouseholdEconomyStarterPacksResponse:
      type: object
      required: [packs]
      properties:
        packs:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdEconomyStarterPack'

    HouseholdEconomyStarterPackApplication:
      type: object
      required: [id, packId, createdTaskIds, createdRewardIds, createdAt]
      properties:
        id:
          type: string
        packId:
          type: string
        createdTaskIds:
          type: array
          items:
            type: string
        createdRewardIds:
          type: array
          items:
            type: string
        createdAt:
          type: string
          format: date-time

    HouseholdEconomyStarterPackApplyResponse:
      type: object
      required: [pack, application, createdTaskIds, rewards]
      properties:
        pack:
          $ref: '#/components/schemas/HouseholdEconomyStarterPack'
        application:
          $ref: '#/components/schemas/HouseholdEconomyStarterPackApplication'
        createdTaskIds:
          type: array
          items:
            type: string
        rewards:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdReward'

    HouseholdRewardCreateRequest:
      type: object
      required: [title, pointCost]
      properties:
        title:
          type: string
        pointCost:
          type: integer
          minimum: 1
          maximum: 999
        isSharedGoal:
          type: boolean
          description: '#1724 Mark as a pooled family savings goal.'
        reusable:
          type: boolean
          default: false
          description: 'Keep the reward active after approved redemptions. Ignored (stored false) when isSharedGoal is true.'

    HouseholdRewardUpdateRequest:
      type: object
      properties:
        title:
          type: string
        pointCost:
          type: integer
          minimum: 1
          maximum: 999
        active:
          type: boolean
        isSharedGoal:
          type: boolean
          description: '#1724 Toggle pooled shared-goal behavior.'
        reusable:
          type: boolean
          description: 'Toggle reusable behavior. Forced false whenever the reward is (or becomes) a shared goal, even if this field is omitted from the request.'

    HouseholdRedemption:
      type: object
      required: [id, householdId, memberId, rewardId, pointCost, status, createdAt]
      properties:
        id:
          type: string
        householdId:
          type: string
        memberId:
          type: string
        rewardId:
          type: string
        rewardTitle:
          type:
            - string
            - 'null'
        pointCost:
          type: integer
        status:
          type: string
          enum: [pending, approved, denied]
        createdAt:
          type: string
          format: date-time
        resolvedAt:
          type:
            - string
            - 'null'
          format: date-time

    HouseholdRedemptionsResponse:
      type: object
      required: [redemptions]
      properties:
        redemptions:
          type: array
          items:
            $ref: '#/components/schemas/HouseholdRedemption'

    HouseholdEconomyRedeemRequest:
      type: object
      required: [rewardId]
      properties:
        rewardId:
          type: string
        contributionAmount:
          type: integer
          minimum: 1
          maximum: 999
          description: '#1724 Partial contribution toward a shared goal. Ignored for standard rewards; omit to contribute the member''s full balance up to the remaining amount.'

    HouseholdEconomyAdjustRequest:
      type: object
      required: [memberId, delta]
      properties:
        memberId:
          type: string
        delta:
          type: integer
          minimum: -10000
          maximum: 10000
        note:
          type:
            - string
            - 'null'

    HouseholdEconomySettingsRequest:
      type: object
      description: >-
        At least one of enabled / fairnessReportEnabled / fairnessReportHidden /
        pointsRestrictedToOwners / approvalRequiredForMembers is required.
      properties:
        enabled:
          type: boolean
        fairnessReportEnabled:
          type: boolean
          description: '#1670 opt-in for the mental-load fairness report (off by default).'
        fairnessReportHidden:
          type: boolean
          description: >-
            Owner-hidable fairness card, independent of fairnessReportEnabled.
            Resurfaced via Household Options.
        pointsRestrictedToOwners:
          type: boolean
          description: >-
            Owner/co-owner-only creation rule. When true, task/chore creation
            by a non-owner/co-owner member always lands at 0 points.
        approvalRequiredForMembers:
          type: boolean
          description: >-
            Owner/co-owner-only creation rule. When true, task/chore creation
            by a non-owner/co-owner member always requires approval.
        baseVersion:
          type: integer
          minimum: 0
          description: >-
            #2166 optimistic-concurrency guard: when sent, the write 409s unless
            it matches the server's current settingsVersion. Omitted = last-write-wins.

    HouseholdEconomySettingsResponse:
      type: object
      required: [economyEnabled, fairnessReportEnabled, fairnessReportHidden, pointsRestrictedToOwners, approvalRequiredForMembers, settingsVersion]
      properties:
        economyEnabled:
          type: boolean
        fairnessReportEnabled:
          type: boolean
        fairnessReportHidden:
          type: boolean
        pointsRestrictedToOwners:
          type: boolean
        approvalRequiredForMembers:
          type: boolean
        settingsVersion:
          type: integer
          minimum: 0

    HouseholdCreateRequest:
      type: object
      properties:
        name:
          type:
            - string
            - 'null'

    HouseholdTaskCreateRequest:
      type: object
      required: [title]
      properties:
        id:
          type: string
          format: uuid
          description: Optional client-generated id for offline/idempotent creation.
        title:
          type: string
        description:
          type:
            - string
            - 'null'
        priority:
          type:
            - integer
            - 'null'
        dueDate:
          type:
            - string
            - 'null'
          format: date-time
        kind:
          type: string
          enum: [shared, errand]
          default: shared
        requiresApproval:
          type: boolean
          default: false
        assigneeId:
          type:
            - string
            - 'null'
        rotation:
          type: boolean
          default: false
        recurrence:
          type:
            - object
            - 'null'
        effortWeight:
          type: integer
          minimum: 1
          maximum: 999
          description: Chore economy point value credited on completion/approval
        taskTier:
          type: string
          enum: [baseline, hustle]
          description: >-
            #1722 baseline chores earn 0 points; hustle chores credit effortWeight.
            Optional: when omitted the server derives it from effortWeight, so a client
            that only sends a point value gets a hustle chore and one that sends none
            gets a baseline chore. An explicit value always wins.
        alarms:
          type: array
          items:
            $ref: '#/components/schemas/Alarm'
          maxItems: 20
          description: >-
            Reminder alarms, same trigger contract as personal TaskCreate. Reminders
            fire for the creator; assignees are covered by the household
            due-today/overdue reminder system.
        shoppingListProjectId:
          type: string
          format: uuid
          description: >-
            #2223 item3: links an errand to one of the creator's own or
            shared-with-them shopping-type projects. Only meaningful when
            kind is 'errand'; ignored otherwise.


    HouseholdAssignRequest:
      type: object
      required: [assigneeId]
      properties:
        assigneeId:
          type: string
        rotation:
          type: boolean
          default: false
        source:
          type: string
          default: manual

    HouseholdRotationRunRequest:
      type: object
      properties:
        reason:
          type:
            - string
            - 'null'

    HouseholdRotationResponse:
      type: object
      required: [assignment, rotationEvent]
      properties:
        assignment:
          $ref: '#/components/schemas/HouseholdChoreAssignment'
        rotationEvent:
          $ref: '#/components/schemas/HouseholdRotationEvent'

    HouseholdInviteCreateRequest:
      type: object
      properties:
        email:
          type: string
          description: Omit to create a shareable link invite instead of emailing one.
        role:
          type: string
          enum: [co-owner, member]
          default: member
          description: >-
            'owner' is not accepted — an accepted invite writes its role onto the
            member row, and ownership moves only through transfer-ownership.

    HouseholdInviteAcceptRequest:
      type: object
      properties:
        inviteId:
          type:
            - string
            - 'null'
        token:
          type:
            - string
            - 'null'

    HouseholdInvitePreview:
      type: object
      required: [householdName, inviterName, role, expiresAt]
      description: >-
        Public (unauthenticated) invite preview for the emailed-link landing
        surfaces. Exposes only what the pre-auth "You've been invited" screen
        needs — the invite token itself is the bearer secret. Never includes
        emails, member lists, or household ids.
      properties:
        householdName:
          type: string
        inviterName:
          type: string
          description: Inviter display name only.
        role:
          type: string
          enum: [owner, co-owner, member]
        expiresAt:
          type: string
          format: date-time

    InvitePreviewError:
      type: object
      required: [code, message]
      description: >-
        Typed errors from GET /api/v1/household/invites/preview. Terminal
        invite states are 410 Gone (matching the accept route's EXPIRED
        semantics); an unknown or masked (archived household) token is 404.
      properties:
        code:
          type: string
          enum:
            - invite_not_found
            - invite_expired
            - invite_used
            - invite_revoked
            - VALIDATION_ERROR
            - RATE_LIMIT_EXCEEDED
            - FEATURE_UNAVAILABLE
            - INTERNAL_ERROR
        message:
          type: string

    HouseholdCreateChildRequest:
      type: object
      required: [displayName, pin]
      properties:
        displayName:
          type: string
          minLength: 1
          maxLength: 120
        pin:
          type: string
          pattern: '^[0-9]{4,6}$'
        dateOfBirth:
          type:
            - string
            - 'null'
          format: date
        e2eeAccessMode:
          $ref: '#/components/schemas/E2eeChildAccessMode'

    HouseholdUpdateChildRequest:
      type: object
      properties:
        displayName:
          type:
            - string
            - 'null'
          minLength: 1
          maxLength: 120
        pin:
          type:
            - string
            - 'null'
          pattern: '^[0-9]{4,6}$'
        dateOfBirth:
          type:
            - string
            - 'null'
          format: date

    HouseholdPinLoginRequest:
      type: object
      required: [pin]
      properties:
        householdId:
          type: string
        memberId:
          type: string
        loginCode:
          type: string
          pattern: '^[a-z0-9-]+$'
        pin:
          type: string
          pattern: '^[0-9]{4,6}$'

    HouseholdPinLoginResponse:
      type: object
      required: [token, memberId, householdId, expiresIn]
      properties:
        token:
          type: string
        memberId:
          type: string
        householdId:
          type: string
        expiresIn:
          type: integer

    # =========================================================================
    # Local-first sync engine — bidirectional ordered change log (#1668 Phase 2)
    # =========================================================================
    SyncChange:
      type: object
      description: |
        A single entry in the ordered change log. `seq` is a monotonic
        server-assigned cursor (Postgres BIGSERIAL) serialized as a decimal
        string because it exceeds the safe-integer range in JSON.

        A row is either personal (`householdId` null) or household-shared. A
        session's feed is the union of its own personal rows and every row scoped
        to a household it belongs to (#1668 Step 1).
      required: [id, seq, entityType, entityId, op, createdAt, protectionMode, compatibilityMemberIds]
      properties:
        id:
          type: string
          format: uuid
        seq:
          type: string
          description: Monotonic ordered cursor (int64, serialized as a decimal string).
          example: "42"
        entityType:
          type: string
          example: task
        entityId:
          type: string
        op:
          type: string
          enum: [upsert, delete]
        payload:
          # Free-form JSON entity snapshot. Enumerated JSON types (not
          # additionalProperties:true, and no explicit null member) so the kotlin
          # generator emits a serializable JsonElement, not an unserializable
          # Map<String,Any> nor an empty oneOf wrapper. Optional (absent from
          # `required`), so it maps to a nullable JsonElement? — null for deletes.
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
            - type: array
          description: Entity snapshot for upserts; absent for deletes.
        clientId:
          oneOf:
            - type: string
            - type: 'null'
          description: Originating device id, so a client can skip its own echoes.
        householdId:
          oneOf:
            - type: string
            - type: 'null'
          description: |
            Household this change is shared with, or null for a personal change.
            Server-resolved — a client-supplied value on push is honoured only when
            the session is a member of that household, and an existing row's stored
            household always wins.
        protectionMode:
          type: string
          enum: [plaintext, e2ee, pin_compatibility]
        encryptionVersion:
          type: [integer, 'null']
        keyEpoch:
          type: [integer, 'null']
        deviceCounter:
          type: [string, 'null']
          description: Per-device monotonic counter serialized as a decimal string.
        signature:
          type: [string, 'null']
          description: Ed25519 signature over canonical metadata and encrypted payload.
        idempotencyKey:
          type: [string, 'null']
          format: uuid
        compatibilityMemberIds:
          type: array
          items:
            type: string
          description: Directly assigned PIN children permitted to receive this reduced row.
        createdAt:
          type: string
          format: date-time
    SyncVerification:
      type: object
      description: |
        Replica self-check metadata, present only when `hasMore` is false (the
        client is fully caught up at `nextCursor`). Absent for child (PIN)
        sessions, whose feed is a protection-mode-restricted subset.
        Fingerprint contract: for each OPEN (non-completed, non-deleted) task
        visible to this session's feed scope, the
        line `${id}:${updatedAtEpochMillis}`; lines sorted by plain code-unit
        order of id, joined with '\n', hashed sha256, lowercase hex. Payload
        contents are excluded so the fingerprint also works for E2EE
        households. A client that computes a different checksum over its local
        replica should log the drift and schedule a full resync (`since=0`);
        the field is advisory and never blocks applying changes.
      required: [entityType, count, checksum]
      properties:
        entityType:
          type: string
          example: task
        count:
          type: integer
          description: Number of live tasks in the fingerprint.
        checksum:
          type: string
          description: sha256 of the fingerprint, lowercase hex.
    SyncChangesPullResponse:
      type: object
      description: Result of a change-log pull (GET, and the pull half of POST).
      required: [changes, nextCursor, hasMore]
      properties:
        changes:
          type: array
          items:
            $ref: '#/components/schemas/SyncChange'
        nextCursor:
          type: string
          description: Pass as `since` on the next pull. Equals the last returned seq, or the input cursor when empty.
          example: "42"
        hasMore:
          type: boolean
        verification:
          $ref: '#/components/schemas/SyncVerification'
    IncomingSyncChange:
      type: object
      required: [entityType, entityId, op]
      properties:
        entityType:
          type: string
          example: task
        entityId:
          type: string
        op:
          type: string
          enum: [upsert, delete, complete, reopen]
          description: |
            `complete`/`reopen` are household-task verbs (#1668 Step 9): valid only
            with `entityType: task`, only for an existing household task, and only
            when that household's `syncProtocol` is `changelog`. The server derives
            completion metadata (`completedAt`/`completedByMemberId`/`status`,
            ledger rows) itself and stores the result as an `upsert` log row — the
            verb never crosses the wire back out.
        payload:
          # Enumerated JSON types (not additionalProperties:true, no null member)
          # so the kotlin generator emits a serializable JsonElement (optional =>
          # nullable) rather than Map<String, Any> or an empty oneOf wrapper.
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
            - type: array
          description: |
            Entity snapshot for upserts; absent for deletes. For a task snapshot a
            `householdId` field inside the payload requests household scope — the
            server honours it only when the session is a member of that household,
            and an existing task's stored household always wins. For household
            tasks on the changelog protocol, client-claimed completion metadata
            (`completed`/`completedAt`/`completedByMemberId`/`status`) is stripped
            — only the complete/reopen verbs may change it. For complete/reopen
            the payload is not a snapshot: an optional `{ memberId }` hint that
            must name the session's own membership.
        clientId:
          type: string
        householdId:
          type: string
        protectionMode:
          type: string
          enum: [plaintext, e2ee, pin_compatibility]
        encryptionVersion:
          type: integer
          minimum: 1
          maximum: 16
        keyEpoch:
          type: integer
          minimum: 1
        deviceCounter:
          type: string
          pattern: '^\d+$'
        signature:
          type: string
        idempotencyKey:
          type: string
          format: uuid
        compatibilityMemberIds:
          type: array
          maxItems: 100
          uniqueItems: true
          items:
            type: string
    PostSyncChangesRequest:
      type: object
      required: [changes]
      properties:
        changes:
          type: array
          minItems: 1
          maxItems: 500
          items:
            $ref: '#/components/schemas/IncomingSyncChange'
        since:
          type: string
          description: Optional pull cursor; server returns changes with seq greater than this in the same response.
          example: "0"
    PostSyncChangesResponse:
      type: object
      description: Push + pull in one round-trip.
      required: [applied, serverChanges, nextCursor, hasMore]
      properties:
        applied:
          type: array
          items:
            type: object
            required: [entityId, seq]
            properties:
              entityId:
                type: string
              seq:
                type: string
                description: Server-assigned seq for the appended change (int64 as string).
        serverChanges:
          type: array
          items:
            $ref: '#/components/schemas/SyncChange'
        nextCursor:
          type: string
        hasMore:
          type: boolean
        verification:
          $ref: '#/components/schemas/SyncVerification'

    SyncChangesErrorResponse:
      description: |
        Error response for POST /api/v1/sync/changes. Extends ErrorResponse
        with `applied` — the committed prefix from before the failure, so the
        client can advance its cursor past what the server did accept even
        when the request as a whole is rejected.
      allOf:
        - $ref: '#/components/schemas/ErrorResponse'
        - type: object
          required: [applied]
          properties:
            applied:
              type: array
              items:
                type: object
                required: [entityId, seq]
                properties:
                  entityId:
                    type: string
                  seq:
                    type: string
                    description: Server-assigned seq for the appended change (int64 as string).


paths:
  /api/v1/capabilities:
    get:
      tags: [capabilities]
      summary: Which optional server integrations are configured
      description: >-
        Booleans only, so clients can hide or disable-with-explanation UI
        for features that can never work on this deployment (self-hosted
        instances routinely run without some of these). Never leaks which
        specific env vars are set or unset.
      operationId: getCapabilities
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Server capability flags
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CapabilitiesResponse'

  /api/v1/focus/recommendations:
    post:
      tags: [focus]
      summary: Get ranked focus recommendations
      operationId: getFocusTopRecommendations
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FocusTopRecommendationsRequest'
      responses:
        '200':
          description: Ranked recommendations with metadata
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FocusTopRecommendationsResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Top3 attribution temporarily disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/focus/top3/served:
    post:
      tags: [focus]
      summary: Record served Today Top-3 tasks
      operationId: recordTodayTop3Served
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FocusTop3ServedRequest'
      responses:
        '200':
          description: Served attribution rows recorded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FocusTop3ServedResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Top3 attribution temporarily disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/focus/top3/completion:
    post:
      tags: [focus]
      summary: Resolve server-side top-3 completion attribution
      operationId: recordTodayTop3Completion
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FocusTop3CompletionRequest'
      responses:
        '200':
          description: Attribution result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FocusTop3CompletionResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/projects:
    get:
      tags: [projects]
      summary: List projects
      description: |
        List all projects owned by or shared with the current user, ordered by `order` ascending.

        **Share metadata**:
        - `shareRole` / `shareStatus` are populated for shared projects; `null` for owned projects.
      operationId: listProjects
      responses:
        '200':
          description: Project list
          content:
            application/json:
              schema:
                type: object
                required: [projects]
                properties:
                  projects:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProjectResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      tags: [projects]
      summary: Create project
      description: Creates a new project (list) owned by the current user.
      operationId: createProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectCreate'
            examples:
              simple:
                summary: Minimal project
                value:
                  name: "Work Tasks"
              withColor:
                summary: Colored project
                value:
                  name: "Work Tasks"
                  description: "Tasks related to work projects"
                  color: "#4A90E2"
      responses:
        '201':
          description: Project created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/projects/{id}:
    get:
      tags: [projects]
      summary: Get project details
      operationId: getProjectById
      description: |
        Retrieve project details including share metadata.

        **Share metadata**:
        - `shareRole`: Current user's role (owner, editor, viewer) if project is shared
        - `shareStatus`: Share status (pending, accepted, etc.) if project is shared
        - Both fields are `null` for owned (non-shared) projects
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Project details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden (no access to this project)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Project not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    patch:
      tags: [projects]
      summary: Update project
      description: Partially update a project. Requires editor or owner role for shared projects.
      operationId: updateProject
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectUpdate'
      responses:
        '200':
          description: Project updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden (viewer role cannot edit)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Project not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      tags: [projects]
      summary: Delete project
      operationId: deleteProject
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Project deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden (only owner can delete)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Project not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/tasks:
    post:
      tags: [shares]
      summary: Create a live task share invite
      operationId: createTaskShare
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareCreateRequest'
      responses:
        '200':
          description: Share created
          content:
            application/json:
              schema:
                type: object
                properties:
                  shareId: { type: string }
                  status: { type: string, enum: [pending, accepted, declined, revoked, archived] }
        '400':
          description: Invalid transition or request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Duplicate share already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/projects:
    post:
      tags: [shares]
      summary: Create a live project (list) share invite
      operationId: createProjectShare
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareCreateRequest'
      responses:
        '200':
          description: Share created
          content:
            application/json:
              schema:
                type: object
                properties:
                  shareId: { type: string }
                  status: { type: string, enum: [pending, accepted, declined, revoked, archived] }
        '400':
          description: Invalid transition or request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Project not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Duplicate share already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/inbox:
    get:
      tags: [shares]
      summary: List incoming shares for current user
      operationId: listIncomingShares
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: List of shares
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ShareSummary'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/sent:
    get:
      tags: [shares]
      summary: List shares sent by current user
      operationId: listSentShares
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: List of sent shares
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ShareSummary'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/{id}/preview:
    get:
      tags: [shares]
      summary: Preview a share
      operationId: previewShare
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Share preview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SharePreview'
        '403':
          description: Preview access only - cannot view full details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '410':
          description: Share no longer available (revoked, declined, or archived)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/{id}/accept:
    post:
      tags: [shares]
      summary: Accept a share
      operationId: acceptShare
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  shareId: { type: string }
                  status: { type: string }
        '400':
          description: Invalid transition (e.g., already accepted or declined)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Duplicate - item already exists in user's library
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/{id}/decline:
    post:
      tags: [shares]
      summary: Decline a share
      operationId: declineShare
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Declined
        '400':
          description: Invalid transition (e.g., already declined)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/{id}/revoke:
    post:
      tags: [shares]
      summary: Revoke a share (sender only)
      operationId: revokeShare
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Revoked
        '400':
          description: Invalid transition (e.g., already revoked)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only the share sender can revoke
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/{id}/archive:
    post:
      tags: [shares]
      summary: Archive a share
      operationId: archiveShare
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Archived
        '400':
          description: Invalid transition
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/{id}/role:
    patch:
      tags: [shares]
      summary: Update role for an accepted share
      operationId: updateShareRole
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareRoleUpdateRequest'
      responses:
        '200':
          description: Role updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  shareId: { type: string }
                  role: { type: string }
        '400':
          description: Invalid transition (e.g., share not accepted)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only the share sender can update roles
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/shares/encrypted:
    post:
      summary: Create an encrypted share link
      operationId: createEncryptedShare
      description: >
        Stores a client-encrypted blob. The ShareKey (decryption key) is never
        transmitted to the server — it lives only in the URL fragment on the client side.
      tags: [shares]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [encryptedBlob]
              properties:
                encryptedBlob:
                  type: string
                  description: JSON-encoded EncryptedPayload {iv, ciphertext} in base64
                expiresInHours:
                  type: integer
                  description: Optional link expiry in hours (default 48)
                  example: 48
      responses:
        '201':
          description: Share link token created
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: Opaque token used in the share URL path
        '400':
          description: Missing or invalid encryptedBlob
        '401':
          description: Unauthenticated

  /api/v1/shares/encrypted/{token}:
    get:
      summary: Retrieve an encrypted share blob
      operationId: getEncryptedShare
      description: >
        Public endpoint — no authentication required. Returns the encrypted blob
        for the given token. The caller must have the ShareKey (from the URL fragment)
        to decrypt the content.
      tags: [shares]
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Encrypted blob retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  encryptedBlob:
                    type: string
                  createdAt:
                    type: string
                    format: date-time
        '404':
          description: Token not found
        '410':
          description: Link has expired

  # =========================================================================
  # E2EE: Key Management
  # =========================================================================
  /api/v1/e2ee/status:
    get:
      tags: [e2ee]
      summary: Get household E2EE and migration status
      operationId: getE2eeStatus
      responses:
        '200':
          description: Household E2EE status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/E2eeStatus'
        '401':
          description: Unauthorized
        '404':
          description: Household membership not found

  /api/v1/e2ee/keys:
    get:
      tags: [e2ee]
      summary: List non-revoked household device keys
      operationId: listE2eeKeys
      responses:
        '200':
          description: List of registered device keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      $ref: '#/components/schemas/E2eeDeviceKey'
        '401':
          description: Unauthorized
    post:
      tags: [e2ee]
      summary: Register this device for household E2EE
      operationId: registerE2eeKey
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/E2eeDeviceKeyRegister'
      responses:
        '201':
          description: Device key registered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/E2eeDeviceKey'
        '401':
          description: Unauthorized
        '409':
          description: Device key already registered

  /api/v1/e2ee/keys/pending:
    get:
      tags: [e2ee]
      summary: List household devices awaiting owner approval
      operationId: listPendingE2eeKeys
      responses:
        '200':
          description: Pending device keys
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items:
                      $ref: '#/components/schemas/E2eeDeviceKey'

  /api/v1/e2ee/keys/self/{deviceId}:
    get:
      tags: [e2ee]
      summary: Get this device's enrollment or wrapped household key
      operationId: getOwnE2eeKey
      parameters:
        - name: deviceId
          in: path
          required: true
          schema:
            type: string
        - name: X-Device-Id
          in: header
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Device key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/E2eeDeviceKey'
        '404':
          description: Device key not found

  /api/v1/e2ee/keys/{deviceId}:
    delete:
      tags: [e2ee]
      summary: Revoke a device key
      operationId: revokeE2eeKey
      parameters:
        - name: deviceId
          in: path
          required: true
          schema:
            type: string
        - name: X-Device-Id
          in: header
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Device key revoked
          content:
            application/json:
              schema:
                type: object
                required: [revokedDeviceId, keyEpoch, rotationRequired]
                properties:
                  revokedDeviceId:
                    type: string
                  keyEpoch:
                    type: integer
                  rotationRequired:
                    type: boolean
        '404':
          description: Device key not found

  /api/v1/e2ee/enable:
    post:
      tags: [e2ee]
      summary: Start the household plaintext-to-E2EE migration
      operationId: enableE2ee
      responses:
        '200':
          description: Migration started
        '400':
          description: No device keys registered

  /api/v1/e2ee/disable:
    post:
      tags: [e2ee]
      summary: Cancel setup before household E2EE activation
      operationId: disableE2ee
      responses:
        '200':
          description: E2EE disabled

  /api/v1/e2ee/keys/rotate:
    post:
      tags: [e2ee]
      summary: Finish key rotation across all active household devices
      operationId: rotateE2eeKey
      parameters:
        - name: X-Device-Id
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [keyEpoch, wrappedKeys]
              properties:
                keyEpoch:
                  type: integer
                wrappedKeys:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [deviceId, wrappedKey]
                    properties:
                      deviceId:
                        type: string
                      wrappedKey:
                        type: string
      responses:
        '200':
          description: Key rotation completed

  /api/v1/e2ee/keys/sync:
    post:
      tags: [e2ee]
      summary: Multi-device wrapped key exchange
      operationId: syncE2eeKeys
      parameters:
        - name: X-Device-Id
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [targetDeviceId, wrappedKey]
              properties:
                wrappedKey:
                  type: string
                targetDeviceId:
                  type: string
      responses:
        '200':
          description: Key synced to target device

  /api/v1/e2ee/children/{memberId}/access:
    patch:
      tags: [e2ee]
      summary: Set one child's E2EE access mode
      operationId: setChildE2eeAccess
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [mode]
              properties:
                mode:
                  $ref: '#/components/schemas/E2eeChildAccessMode'
                compatibilityTasks:
                  type: array
                  maxItems: 500
                  default: []
                  description: Complete reduced snapshots for directly assigned tasks, required when downgrading an active scoped child to PIN compatibility.
                  items:
                    $ref: '#/components/schemas/E2eeChildCompatibilityTask'
      responses:
        '200':
          description: Child access mode updated
        '404':
          description: Child member not found

  /api/v1/e2ee/recovery:
    put:
      tags: [e2ee]
      summary: Store a passphrase-protected household recovery envelope
      operationId: putE2eeRecovery
      parameters:
        - name: X-Device-Id
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recoveryEnvelope]
              properties:
                recoveryEnvelope:
                  type: object
                  required: [version, kdf, salt, memoryKiB, iterations, parallelism, nonce, ciphertext]
                  properties:
                    version:
                      type: integer
                      enum: [1]
                    kdf:
                      type: string
                      enum: [argon2id]
                    salt:
                      type: string
                    memoryKiB:
                      type: integer
                      minimum: 65536
                    iterations:
                      type: integer
                      minimum: 3
                    parallelism:
                      type: integer
                      minimum: 1
                    nonce:
                      type: string
                    ciphertext:
                      type: string
      responses:
        '204':
          description: Recovery envelope stored

  /api/v1/e2ee/migration/verify:
    post:
      tags: [e2ee]
      summary: Record and verify the encrypted migration inventory
      operationId: verifyE2eeMigration
      parameters:
        - name: X-Device-Id
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [encryptedEntityCount, plaintextEntityCount, inventoryChecksum]
              properties:
                encryptedEntityCount:
                  type: integer
                  minimum: 0
                plaintextEntityCount:
                  type: integer
                  minimum: 0
                inventoryChecksum:
                  type: string
      responses:
        '200':
          description: Migration inventory accepted for activation
        '409':
          description: Plaintext entities remain

  /api/v1/e2ee/migration/activate:
    post:
      tags: [e2ee]
      summary: Activate household E2EE after verified migration and recovery setup
      operationId: activateE2eeMigration
      parameters:
        - name: X-Device-Id
          in: header
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Household E2EE activated
        '409':
          description: Migration or recovery setup is incomplete

  # =========================================================================
  # Zapier Integration
  # =========================================================================
  /api/v1/integrations/zapier/inbound:
    post:
      tags: [zapier]
      summary: Inbound trigger from Zapier
      description: Creates or updates a task from a Zapier trigger. Authenticated via HMAC-SHA256 signature.
      operationId: zapierInbound
      parameters:
        - name: X-Zapier-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ZapierInboundTrigger'
      responses:
        '200':
          description: Task created or updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  taskId:
                    type: string
        '401':
          description: Invalid HMAC signature

  /api/v1/integrations/zapier/secret:
    get:
      tags: [zapier]
      summary: Get or generate webhook secret
      operationId: getZapierSecret
      responses:
        '200':
          description: Webhook secret
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ZapierSecret'

  /api/v1/integrations/zapier/secret/rotate:
    post:
      tags: [zapier]
      summary: Rotate webhook secret
      operationId: rotateZapierSecret
      responses:
        '200':
          description: New secret generated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ZapierSecret'

  /api/v1/webhook-signing-secret:
    get:
      tags: [webhook-signing-secret]
      summary: Get or lazily provision the account's webhook signing secret
      operationId: getWebhookSigningSecret
      responses:
        '200':
          description: Webhook signing secret
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookSigningSecret'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Requires a first-party session (STEP_UP_REQUIRED) — app passwords and personal access tokens are rejected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Could not get or provision the secret
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/webhook-signing-secret/rotate:
    post:
      tags: [webhook-signing-secret]
      summary: Rotate the account's webhook signing secret
      operationId: rotateWebhookSigningSecret
      responses:
        '200':
          description: New secret generated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookSigningSecret'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Requires a first-party session (STEP_UP_REQUIRED) — app passwords and personal access tokens are rejected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Could not rotate the secret
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/integrations/zapier/subscriptions:
    get:
      tags: [zapier]
      summary: List active webhook subscriptions
      operationId: listZapierSubscriptions
      responses:
        '200':
          description: List of subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscriptions:
                    type: array
                    items:
                      $ref: '#/components/schemas/ZapierSubscription'
    post:
      tags: [zapier]
      summary: Register a webhook subscription
      operationId: createZapierSubscription
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ZapierSubscriptionCreate'
      responses:
        '201':
          description: Subscription created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ZapierSubscription'

  /api/v1/integrations/zapier/subscriptions/{id}:
    delete:
      tags: [zapier]
      summary: Delete a webhook subscription
      operationId: deleteZapierSubscription
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Subscription deleted
        '404':
          description: Subscription not found

  /api/v1/integrations/zapier/deliveries:
    get:
      tags: [zapier]
      summary: Get delivery log
      operationId: listZapierDeliveries
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        '200':
          description: Delivery log entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items:
                      $ref: '#/components/schemas/ZapierDeliveryLog'

  /api/v1/sync/changes:
    get:
      tags: [sync]
      summary: Pull ordered change-log entries
      description: |
        Local-first sync (#1668 Phase 2). Returns change-log entries with `seq`
        greater than the `since` cursor, ordered ascending, capped by `limit`.
        The feed is the union of the session's personal rows and every row scoped
        to a household it belongs to. A child (PIN) session receives its
        household's rows only — never personal rows, matching the child boundary
        enforced elsewhere. Live by default since the #1668 rollout — returns
        404 only when the server explicitly disables them
        (`TAPTIDY_SYNC_CHANGES_ENABLED=false`, the rollback escape hatch).
        Requires the `syncMode: auto` entitlement.
      operationId: pullSyncChanges
      parameters:
        - name: since
          in: query
          description: Cursor (decimal string). `0` or omitted means from the beginning.
          schema:
            type: string
            default: "0"
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 200
        - name: Prefer
          in: header
          required: false
          description: |
            `stream` switches the response to NDJSON streaming mode
            (`application/x-ndjson`); sending `Accept: application/x-ndjson`
            selects the same mode. The backlog after `since` is written as
            one `SyncChange` JSON object per line, then the connection is held
            open and new changes are appended as they land. A heartbeat line
            `{"heartbeat":true}` is written during idle periods, and the server
            closes the stream after a bounded duration - reconnect with the
            last seen `seq` as `since`; the cursor makes reconnects lossless.
            Streams are capped per user; excess requests get 429.
          schema:
            type: string
            enum: [stream]
      responses:
        '200':
          description: |
            A page of change-log entries - or, with `Prefer: stream` (or
            `Accept: application/x-ndjson`), an NDJSON stream of `SyncChange`
            lines interleaved with heartbeat lines.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncChangesPullResponse'
            application/x-ndjson:
              schema:
                type: string
                description: One SyncChange JSON object (or heartbeat) per line.
        '429':
          description: Stream capacity reached for this session (concurrent-stream cap).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '400':
          description: Invalid cursor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Auto-sync entitlement required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Feature disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [sync]
      summary: Push changes and pull server changes in one round-trip
      description: |
        Local-first sync (#1668). The server is a pure ordered relay (#1668
        Step 10): it appends each incoming change to the log (assigning `seq`),
        stores task snapshots verbatim — arbitration already happened on the
        clients through the shared Rust event log — and hard-deletes
        scope-bounded. Each change is resolved to a personal or household scope
        from server-side truth. Then returns server changes with `seq` greater
        than the optional `since` cursor. Live by default since the rollout
        (404 only when explicitly disabled); requires the `syncMode: auto`
        entitlement.
      operationId: pushSyncChanges
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostSyncChangesRequest'
      responses:
        '200':
          description: Applied changes plus the pulled server changes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostSyncChangesResponse'
        '400':
          description: Invalid change set
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Auto-sync entitlement required, or a change was pushed outside the
            scope this session may write. That covers a child session pushing a
            personal change, and any push targeting a household task in a
            household whose `syncProtocol` is still `merge`. Households on the
            `changelog` protocol accept task updates/deletes and the
            complete/reopen verbs here, authorized through the same invariant
            module the REST routes use; the verb's result is stored and fanned
            out as a server-derived `upsert` row. Household task CREATION stays
            on `/api/v1/household/tasks` regardless of protocol. The body echoes
            `applied` — the committed prefix — so the client can advance past
            what the server did accept.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncChangesErrorResponse'
        '404':
          description: Feature disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks:
    get:
      tags: [tasks]
      summary: List tasks
      description: |
        Retrieve a list of tasks with optional filtering.

        **Default behavior**: Returns active (non-completed) tasks only.
        To include completed tasks, use `includeCompleted=true`.

        **Due date canonicalization**:
        - All-day tasks: `dueDate=null`, `dueDateRaw="YYYY-MM-DD"`
        - Timed tasks: `dueDate="ISO timestamp"`, `dueDateRaw=null`
      operationId: listTasks
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: completed
          in: query
          description: Filter by completion status
          schema:
            type: boolean
        - name: includeCompleted
          in: query
          description: Include completed tasks in results
          schema:
            type: boolean
            default: false
        - name: archived
          in: query
          description: |
            Filter by archive status (archivedAt set vs null). Independent of
            `completed` — an archived task is usually also completed, but not
            exclusively. Pass `includeCompleted=true` alongside `archived=true`
            to avoid the default completed:false filter excluding archived-and-
            completed tasks.
          schema:
            type: boolean
        - name: updatedAfter
          in: query
          description: Only return tasks updated after this timestamp
          schema:
            type: string
            format: date-time
        - name: sharedOnly
          in: query
          description: Only return tasks that are shared with the current user
          schema:
            type: boolean
            default: false
        - name: orderBy
          in: query
          description: >-
            #2359: sort order. Default `updatedAt desc` is not a stable
            pagination key (ties, and a row's updatedAt can change between
            page fetches, reordering rows already returned) — pass `id` for a
            tie-free total order whose relative row ordering never changes
            across pages/cycles. Pagination itself is still offset-based, not
            a keyset/`afterId` cursor: a row inserted or deleted ahead of the
            current offset between fetches can still shift what that offset
            returns. `id` ordering removes the reordering hazard, not the
            offset-drift one.
          schema:
            type: string
            enum: [id]
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskListResponse'
              examples:
                allDayTask:
                  summary: All-day task with PT0S alarm
                  value:
                    tasks:
                      - id: "550e8400-e29b-41d4-a716-446655440000"
                        title: "Annual review"
                        completed: false
                        priority: 2
                        dueDate: null
                        dueDateRaw: "2026-02-15"
                        alarms:
                          - trigger: "PT0S"
                            action: "DISPLAY"
                        tags: []
                        isShoppingItem: false
                        groceryCategory: null
                        order: 0
                        createdAt: "2026-02-10T10:00:00Z"
                        updatedAt: "2026-02-11T10:00:00Z"
                        syncMetadata:
                          version: 1
                          needsSync: false
                          lastSyncAt: "2026-02-11T10:00:00Z"
                    total: 1
                    hasMore: false
                timedTask:
                  summary: Timed task with -PT15M alarm
                  value:
                    tasks:
                      - id: "660e8400-e29b-41d4-a716-446655440001"
                        title: "Team meeting"
                        completed: false
                        priority: 1
                        dueDate: "2026-02-15T14:00:00Z"
                        dueDateRaw: null
                        alarms:
                          - trigger: "-PT15M"
                            action: "DISPLAY"
                        tags: ["work"]
                        isShoppingItem: false
                        groceryCategory: null
                        order: 0
                        createdAt: "2026-02-10T10:00:00Z"
                        updatedAt: "2026-02-11T10:00:00Z"
                        syncMetadata:
                          version: 1
                          needsSync: false
                          lastSyncAt: "2026-02-11T10:00:00Z"
                    total: 1
                    hasMore: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      tags: [tasks]
      summary: Create a new task
      description: |
        Create a single task with optional due date and alarms.

        **Due date rules**:
        - For all-day tasks: set `dueDateRaw` only
        - For timed tasks: set `dueDate` only
        - Do not set both (dueDateRaw takes precedence)

        **Alarm resolution**:
        - For all-day tasks with PT0S trigger, Android resolves using user's `allDayReminderHour/Minute` setting
        - For timed tasks with PT0S, triggers at exact due time
      operationId: createTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskCreate'
            examples:
              allDayTask:
                summary: All-day task
                value:
                  title: "Annual review"
                  dueDateRaw: "2026-02-15"
                  priority: 2
                  alarms:
                    - trigger: "PT0S"
              timedTask:
                summary: Timed task
                value:
                  title: "Team meeting"
                  dueDate: "2026-02-15T14:00:00Z"
                  priority: 1
                  alarms:
                    - trigger: "-PT15M"
                      action: "DISPLAY"
      responses:
        '201':
          description: Task created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/batch:
    post:
      tags: [tasks]
      summary: Batch task operations
      description: |
        Perform multiple create/update/delete operations in a single request.
        Operations are processed independently; partial failures return errors array.
      operationId: batchTaskOperations
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskBatchRequest'
      responses:
        '200':
          description: All batch operations completed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskBatchResponse'
        '207':
          description: Multi-Status - Partial success (some operations failed, check errors array)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskBatchResponse'
        '400':
          description: Invalid request format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}:
    patch:
      tags: [tasks]
      summary: Update a task
      description: Partially update a task. Only provided fields are updated.
      operationId: updateTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskUpdate'
      responses:
        '200':
          description: Task updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [tasks]
      summary: Delete a task
      description: >-
        #2235: soft-deletes by default (30-day restore window via
        POST /{id}/restore). ?permanent=true skips the restore window and
        hard-deletes immediately — purge worker/GDPR export tooling only,
        never wired to end-user delete UI.
      operationId: deleteTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: permanent
          in: query
          required: false
          schema:
            type: boolean
      responses:
        '204':
          description: Task deleted successfully
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/restore:
    post:
      tags: [tasks]
      summary: Restore a soft-deleted task
      description: >-
        #2235: undo a soft delete within the 30-day retention window. 404s
        once purged.
      operationId: restoreTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: If-Match
          in: header
          required: false
          description: >-
            #2358 optimistic-concurrency guard: when sent (or the equivalent
            expectedVersion body field — If-Match takes precedence when both
            are given), the restore 409s unless it matches the task's current
            syncMetadata.version. Omitted = restore unconditionally. Prevents
            a delayed fallback restore from resurrecting a task a later
            delete already re-deleted. Both the delete and the restore
            transitions bump syncVersion, so a stale value from before either
            can never match again. Strictly validated as a non-negative
            integer — a malformed value ("5abc", "5.9") is a 400
            VALIDATION_ERROR, not silently treated as absent.
          schema:
            type: string
            pattern: '^\d+$'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                expectedVersion:
                  type: integer
                  description: Alternative to the If-Match header, same semantics.
      responses:
        '204':
          description: Task restored successfully
        '400':
          description: >-
            expectedVersion/If-Match was supplied but is not a valid
            integer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found (never deleted, or already purged)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            expectedVersion/If-Match did not match the task's current
            version — something else changed the task since the caller
            decided to restore it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskRestoreVersionConflict'

  /api/v1/templates:
    get:
      tags: [templates]
      summary: List templates
      operationId: listTemplates
      parameters:
        - name: search
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: Template list response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateListResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [templates]
      summary: Create template
      operationId: createTemplate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TemplateCreate'
      responses:
        '201':
          description: Template created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/templates/batch:
    post:
      tags: [templates]
      summary: Bulk upsert the caller's templates
      description: >-
        Upserts the submitted templates within the caller's scope (max 100 per
        request). Items whose id matches a system template or another user's
        template are skipped. This is the write-back path used by the Android
        client's template sync.
      operationId: batchUpsertTemplates
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              maxItems: 100
              items:
                $ref: '#/components/schemas/TemplateBatchItem'
      responses:
        '200':
          description: Batch upsert result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateBatchResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Template usage limit reached
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/templates/{id}:
    get:
      tags: [templates]
      summary: Get template by id
      operationId: getTemplate
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Template response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags: [templates]
      summary: Update template
      operationId: updateTemplate
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TemplateUpdate'
      responses:
        '200':
          description: Updated template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [templates]
      summary: Delete template
      operationId: deleteTemplate
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Template deleted
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/templates/{id}/instantiate:
    post:
      tags: [templates]
      summary: Create task from template
      operationId: instantiateTemplate
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TemplateInstantiateRequest'
      responses:
        '201':
          description: Task created from template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateInstantiateResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/archive:
    post:
      tags: [tasks]
      summary: Archive a task
      description: Move a task to archived state (soft delete)
      operationId: archiveTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Task archived successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/unarchive:
    post:
      tags: [tasks]
      summary: Unarchive a task
      description: Restore a task from archived state
      operationId: unarchiveTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Task unarchived successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ── Bulk Archive / Fresh Start (Issue #852) ──────────────────────────────

  /api/v1/tasks/bulk-archive/preview:
    post:
      tags: [tasks, bulk-archive]
      summary: Preview bulk archive (dry run)
      description: Returns count and sample of tasks that would be archived given the supplied criteria. Does not modify any data.
      operationId: previewBulkArchive
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [criteria]
              properties:
                criteria:
                  $ref: '#/components/schemas/BulkArchiveCriteria'
      responses:
        '200':
          description: Preview result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkArchivePreviewResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/bulk-archive:
    post:
      tags: [tasks, bulk-archive]
      summary: Execute bulk archive
      description: Archives all tasks matching the supplied criteria. Returns the operation ID which can be used to undo within 24 hours.
      operationId: executeBulkArchive
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [criteria]
              properties:
                criteria:
                  $ref: '#/components/schemas/BulkArchiveCriteria'
                options:
                  type: object
                  properties:
                    archiveReason:
                      type: string
                      maxLength: 200
      responses:
        '200':
          description: Archive executed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkArchiveResult'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/bulk-archive/undo:
    post:
      tags: [tasks, bulk-archive]
      summary: Undo a bulk archive operation
      description: Restores all tasks from a previous bulk archive operation. Must be called within 24 hours of the original archive.
      operationId: undoBulkArchive
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operationId]
              properties:
                operationId:
                  type: string
                  format: uuid
      responses:
        '200':
          description: Undo successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  restoredCount:
                    type: integer
        '400':
          description: Undo window expired or operation already undone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/bulk-archive/history:
    get:
      tags: [tasks, bulk-archive]
      summary: List bulk archive/delete history
      description: Returns the last 20 bulk operations for the current user.
      operationId: getBulkArchiveHistory
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Operation history
          content:
            application/json:
              schema:
                type: object
                properties:
                  operations:
                    type: array
                    items:
                      $ref: '#/components/schemas/ArchiveOperation'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/bulk-delete:
    post:
      tags: [tasks, bulk-archive]
      summary: Permanently delete tasks in bulk
      description: "Permanently deletes all tasks matching the supplied criteria. This action cannot be undone. Requires `confirmDelete: true` in the request body."
      operationId: executeBulkDelete
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [criteria, confirmDelete]
              properties:
                criteria:
                  $ref: '#/components/schemas/BulkArchiveCriteria'
                confirmDelete:
                  type: string
                  enum: ["true"]
                options:
                  type: object
                  properties:
                    archiveReason:
                      type: string
                      maxLength: 200
      responses:
        '200':
          description: Delete executed
          content:
            application/json:
              schema:
                type: object
                properties:
                  operationId:
                    type: string
                    format: uuid
                  deletedCount:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/user/fresh-start:
    post:
      tags: [user, bulk-archive]
      summary: Fresh start — archive all tasks
      description: Archives all tasks (optionally preserving recurring and shared tasks). Returns the operation ID for undo within 24 hours.
      operationId: freshStart
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FreshStartRequest'
      responses:
        '200':
          description: Fresh start executed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FreshStartResult'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/user/fresh-start-delete:
    post:
      tags: [user, bulk-archive]
      summary: Fresh start — permanently delete all tasks
      description: "Permanently deletes all tasks (optionally preserving recurring and shared tasks). This action cannot be undone. Requires `confirmDelete: true`."
      operationId: freshStartDelete
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/FreshStartRequest'
                - type: object
                  required: [confirmDelete]
                  properties:
                    confirmDelete:
                      type: string
                      enum: ["true"]
      responses:
        '200':
          description: Delete executed
          content:
            application/json:
              schema:
                type: object
                properties:
                  operationId:
                    type: string
                    format: uuid
                  deletedCount:
                    type: integer
                  preservedCount:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/attachments:
    post:
      tags: [tasks]
      summary: Upload a task attachment
      description: |
        Upload one file (multipart field `file`) as an attachment on the task.
        Limits: 10 MB per file, 20 attachments per task, allowlisted mime types
        (images, PDF, plain text/CSV, Office documents). The display filename is
        sanitized metadata; storage uses a server-generated name.
      operationId: uploadTaskAttachment
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '201':
          description: Attachment created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskAttachment'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Per-task attachment limit reached
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: File exceeds the size limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '415':
          description: Unsupported attachment type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    get:
      tags: [tasks]
      summary: List task attachments
      operationId: listTaskAttachments
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Attachment metadata for the task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskAttachmentListResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/attachments/{attachmentId}:
    get:
      tags: [tasks]
      summary: Download a task attachment
      description: Streams the attachment bytes with its stored content type.
      operationId: downloadTaskAttachment
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: attachmentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Attachment bytes
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '404':
          description: Task or attachment not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [tasks]
      summary: Delete a task attachment
      operationId: deleteTaskAttachment
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: attachmentId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Attachment deleted
        '404':
          description: Task or attachment not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/clarify:
    post:
      tags: [tasks]
      summary: Set needs-clarification flag on a task
      description: Toggle the needsClarification flag. Set to true to flag a task for follow-up; false to mark it clarified.
      operationId: clarifyTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [needsClarification]
              properties:
                needsClarification:
                  type: boolean
                  example: true
      responses:
        '200':
          description: Task clarification status updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/skip:
    post:
      tags: [tasks]
      summary: Skip the current occurrence of a recurring task
      description: Advances the task's due date to the next recurrence occurrence without marking it completed
      operationId: skipTaskOccurrence
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Occurrence skipped successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Task is not recurring
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{id}/stop-recurring:
    post:
      tags: [tasks]
      summary: Complete a task and stop its recurrence
      description: Marks the task as completed and removes its recurrence rule, ending the series
      operationId: stopTaskRecurring
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Task completed and recurrence stopped
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ============================================================================
  # FCM Endpoints
  # ============================================================================

  /api/v1/fcm/register:
    post:
      tags: [fcm]
      summary: Register FCM device token
      description: |
        Register a Firebase Cloud Messaging device token for push notifications.
        Uses upsert semantics — re-registering an existing token updates its `lastUsedAt`.
      operationId: registerFcmToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FcmRegisterRequest'
            examples:
              android:
                summary: Android device
                value:
                  token: "fcm-token-abc123"
                  platform: android
      responses:
        '200':
          description: Token registered (or refreshed)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FcmRegisterResponse'
        '400':
          description: Missing device token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/fcm/unregister:
    post:
      tags: [fcm]
      summary: Unregister FCM device token
      description: Remove a device token so push notifications are no longer sent to it.
      operationId: unregisterFcmToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FcmUnregisterRequest'
      responses:
        '200':
          description: Token removed
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    example: true
        '400':
          description: Missing device token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/fcm/devices:
    get:
      tags: [fcm]
      summary: List registered devices
      description: List all FCM-registered devices for the current user, ordered by most recently used.
      operationId: listFcmDevices
      responses:
        '200':
          description: Device list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FcmDeviceListResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ── UnifiedPush ──────────────────────────────────────────────────────────────

  /api/v1/up/register:
    post:
      tags: [unified-push]
      summary: Register UnifiedPush endpoint
      description: |
        Register a UnifiedPush distributor endpoint URL for push notifications.
        Uses upsert semantics — re-registering an existing endpoint updates its `lastUsedAt`.
      operationId: registerUpEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UnifiedPushRegisterRequest'
            examples:
              android:
                summary: Android device via ntfy
                value:
                  endpoint: "https://ntfy.sh/up-abc123"
                  platform: android
      responses:
        '200':
          description: Endpoint registered (or refreshed)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnifiedPushRegisterResponse'
        '400':
          description: Missing endpoint URL
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/up/unregister:
    post:
      tags: [unified-push]
      summary: Unregister UnifiedPush endpoint
      description: Remove a UnifiedPush endpoint so push notifications are no longer sent to it.
      operationId: unregisterUpEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UnifiedPushUnregisterRequest'
      responses:
        '200':
          description: Endpoint removed
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    example: true
        '400':
          description: Missing endpoint URL
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/up/devices:
    get:
      tags: [unified-push]
      summary: List registered UnifiedPush endpoints
      description: List all UnifiedPush endpoints for the current user, ordered by most recently used.
      operationId: listUpDevices
      responses:
        '200':
          description: Endpoint list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnifiedPushDeviceListResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ============================================================================
  # Provider Endpoints
  # ============================================================================

  /api/v1/providers:
    get:
      tags: [providers]
      summary: List configured providers
      description: List all external provider integrations for the current user.
      operationId: listProviders
      responses:
        '200':
          description: Provider list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderListResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      tags: [providers]
      summary: Create provider integration
      description: |
        Configure a new external provider. Creates the provider record, stores
        credentials securely, and performs an initial connectivity validation.

        Only one provider of each `type` is allowed per user.
      operationId: createProvider
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProviderCreateRequest'
            examples:
              todoist:
                summary: Todoist via API token
                value:
                  type: todoist
                  config:
                    apiToken: "0123456789abcdef0123456789abcdef01234567"
              caldav:
                summary: CalDAV server
                value:
                  type: caldav
                  config:
                    url: "https://caldav.example.com/dav/"
                    username: "user@example.com"
                    password: "s3cr3t"
      responses:
        '201':
          description: Provider created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Provider type already configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/todoist/validate:
    post:
      tags: [providers]
      summary: Validate Todoist API token (pre-registration)
      description: |
        Validates a Todoist API token and returns the user's project list for
        configuration UI display. This is step 1 of the 2-step Todoist setup flow.

        **Note**: Uses HTTP 400 (not 401) for invalid Todoist tokens to avoid
        triggering frontend login redirects.
      operationId: validateTodoistToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TodoistValidateRequest'
      responses:
        '200':
          description: Token is valid — returns project list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TodoistValidateResponse'
        '400':
          description: Invalid API token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/todoist/oauth/callback:
    post:
      tags: [providers]
      summary: Exchange Todoist OAuth authorization code
      description: |
        Exchanges an OAuth authorization code for an access token, then validates
        the token and creates (or updates) the Todoist provider record.

        Requires `TODOIST_CLIENT_ID` and `TODOIST_CLIENT_SECRET` env vars to be set;
        returns 501 if OAuth is not configured.
      operationId: todoistOAuthCallback
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TodoistOAuthCallbackRequest'
      responses:
        '200':
          description: Todoist connected successfully
          content:
            application/json:
              schema:
                type: object
                required: [success, providerId, message]
                properties:
                  success:
                    type: boolean
                    example: true
                  providerId:
                    type: string
                    format: uuid
                  message:
                    type: string
                    example: "Todoist connected successfully"
        '400':
          description: Invalid or expired OAuth authorization code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: Provider OAuth exchange failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '501':
          description: OAuth not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}:
    get:
      tags: [providers]
      summary: Get provider details
      operationId: getProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Provider details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    patch:
      tags: [providers]
      summary: Update provider configuration
      description: Update sync settings (mode, interval, project selection, active state).
      operationId: updateProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProviderUpdateRequest'
      responses:
        '200':
          description: Provider updated
          content:
            application/json:
              schema:
                type: object
                required: [success, message]
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: "Provider updated"
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/validate:
    post:
      tags: [providers]
      summary: Re-validate provider credentials
      description: Re-check stored provider credentials and update health status.
      operationId: validateProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Validation result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderValidationResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/enable:
    post:
      tags: [providers]
      summary: Enable provider
      description: Enable the provider for sync operations.
      operationId: enableProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Provider enabled
          content:
            application/json:
              schema:
                type: object
                required: [success, message]
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/disable:
    post:
      tags: [providers]
      summary: Disable provider
      description: Disable the provider so it no longer participates in sync.
      operationId: disableProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Provider disabled
          content:
            application/json:
              schema:
                type: object
                required: [success, message]
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/status:
    get:
      tags: [providers]
      summary: Get detailed provider health status
      description: |
        Returns detailed health information including consecutive failure count, last successful sync,
        token validity hint, effective timezone, health summary, and provider drift signals.
      operationId: getProviderStatus
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Provider status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderStatusResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/todoist/projects:
    get:
      tags: [providers]
      summary: List a connected Todoist provider's projects with task counts
      description: |
        Uses the provider's stored (already-validated) API token to fetch its
        current project list, annotated with per-project task counts, for the
        project-scope picker in the sync-configuration UI.
      operationId: getTodoistProviderProjects
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Todoist projects for this provider
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TodoistProjectsResponse'
        '400':
          description: Provider is not a Todoist provider, or has no stored credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/check:
    post:
      tags: [providers]
      summary: Dry-run provider verification
      description: |
        Runs non-mutating verification checks against the provider: credential presence,
        token/credential validity, connectivity, and drift detection. Does not modify any
        local or remote state. Useful for troubleshooting provider failures without log access.
      operationId: checkProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Check results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderCheckResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/fetch:
    post:
      tags: [providers]
      summary: Preview tasks from provider (read-only fetch)
      description: |
        Fetches raw tasks from the external provider without importing them.
        Useful for previewing data before committing to an import.
      operationId: fetchProviderTasks
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Raw tasks from provider
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderFetchResponse'
        '400':
          description: Provider not configured or unsupported
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/sync:
    post:
      tags: [providers]
      summary: Trigger manual provider sync
      description: |
        Runs a full bidirectional sync cycle for the given provider.
        Provider must be active. Currently only Todoist is supported.
      operationId: syncProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Sync completed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderSyncResponse'
        '400':
          description: Provider inactive, unsupported, or missing credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/providers/{id}/import-bulk:
    post:
      tags: [providers]
      summary: Bulk import all tasks from provider
      description: |
        One-shot import of all tasks from the external provider, ignoring the
        configured sync mode. Useful for initial data migration.
        Currently only Todoist is supported.
      operationId: bulkImportProvider
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Import completed
          content:
            application/json:
              schema:
                type: object
                required: [success, message, imported]
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: "Bulk import completed"
                  imported:
                    type: integer
                    example: 152
                  conflicts:
                    type: integer
                    example: 0
        '400':
          description: Provider not configured or unsupported
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Provider not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ============================================================================
  # Tag Endpoints
  # ============================================================================

  /api/v1/tags:
    get:
      tags: [tags]
      summary: List tags
      description: Returns all tags for the current user, ordered by `order` ascending.
      operationId: listTags
      responses:
        '200':
          description: Tag list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagListResponse'
              examples:
                withHierarchy:
                  summary: Flat and nested tags
                  value:
                    tags:
                      - id: "550e8400-e29b-41d4-a716-446655440000"
                        name: "work"
                        color: "#4A90E2"
                        parentId: null
                        order: 0
                        createdAt: "2026-02-10T10:00:00Z"
                        updatedAt: "2026-02-11T10:00:00Z"
                        syncMetadata:
                          version: 1
                          needsSync: false
                          lastSyncAt: "2026-02-11T10:00:00Z"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      tags: [tags]
      summary: Create tag
      description: |
        Create a new tag. Tag names must be non-empty.
        Tags can be nested using `parentId` for hierarchical organization.
      operationId: createTag
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TagCreate'
            examples:
              simple:
                summary: Simple tag
                value:
                  name: "work"
              withColor:
                summary: Colored tag with parent
                value:
                  name: "frontend"
                  color: "#E74C3C"
                  parentId: "550e8400-e29b-41d4-a716-446655440000"
      responses:
        '201':
          description: Tag created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagResponse'
        '400':
          description: Validation error (e.g., blank name)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tags/{id}:
    patch:
      tags: [tags]
      summary: Update tag
      description: Partially update a tag. Only provided fields are changed.
      operationId: updateTag
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TagUpdate'
      responses:
        '200':
          description: Tag updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Tag not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      tags: [tags]
      summary: Delete tag
      operationId: deleteTag
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Tag deleted
        '404':
          description: Tag not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ============================================================================
  # Stats Endpoints
  # ============================================================================

  /api/v1/stats/weekly:
    get:
      tags: [stats]
      summary: Weekly completion counts
      description: Returns task completion counts per day for the requested time range.
      operationId: getWeeklyStats
      parameters:
        - name: range
          in: query
          schema:
            type: string
            enum: [today, week, month, year, all]
            default: week
        - name: projectId
          in: query
          schema:
            type: string
            format: uuid
        - name: tagId
          in: query
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Weekly completion data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WeeklyStatsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/stats/distribution:
    get:
      tags: [stats]
      summary: Task completion time distribution
      description: Returns task completion counts bucketed by hour of day.
      operationId: getTimeDistribution
      parameters:
        - name: range
          in: query
          schema:
            type: string
            enum: [today, week, month, year, all]
            default: week
        - name: projectId
          in: query
          schema:
            type: string
            format: uuid
        - name: tagId
          in: query
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Time distribution by hour
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TimeDistributionResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/stats/today:
    get:
      tags: [stats]
      summary: Daily summary
      description: Returns today's task completion summary including overdue count.
      operationId: getDailySummary
      parameters:
        - name: range
          in: query
          schema:
            type: string
            enum: [today, week, month, year, all]
            default: today
        - name: projectId
          in: query
          schema:
            type: string
            format: uuid
        - name: tagId
          in: query
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Daily summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DailySummaryResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/stats/consistency:
    get:
      tags: [stats]
      summary: Routine consistency
      description: Returns per-day completion history and current streak for habit tracking.
      operationId: getRoutineConsistency
      parameters:
        - name: range
          in: query
          schema:
            type: string
            enum: [today, week, month, year, all]
            default: month
        - name: projectId
          in: query
          schema:
            type: string
            format: uuid
        - name: tagId
          in: query
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Consistency data with streak
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineConsistencyResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/stats/kpis:
    get:
      tags: [stats]
      summary: Key performance indicators
      description: Returns aggregate KPIs including total completed, completion rate, average daily completed, and longest streak.
      operationId: getKPIs
      parameters:
        - name: range
          in: query
          schema:
            type: string
            enum: [today, week, month, year, all]
            default: month
        - name: projectId
          in: query
          schema:
            type: string
            format: uuid
        - name: tagId
          in: query
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          schema:
            type: string
      responses:
        '200':
          description: KPI metrics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KPIsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/stats/weekly-wins:
    get:
      tags: [stats]
      summary: This week's wins
      description: Returns completed tasks from the current week for celebration/review.
      operationId: getWeeklyWins
      parameters:
        - name: range
          in: query
          schema:
            type: string
            enum: [today, week, month, year, all]
            default: week
        - name: projectId
          in: query
          schema:
            type: string
            format: uuid
        - name: tagId
          in: query
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Weekly wins list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WeeklyWinsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Auth endpoints
  # =========================================================================
  /api/v1/auth/register:
    post:
      tags: [auth]
      summary: Register a new user
      operationId: registerUser
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: >-
            User registered and logged in. A 30-day session is issued
            immediately (cookie for web, `sessionToken` for native clients);
            email verification happens in the background. `sessionToken` and
            `expiresAt` are omitted in the rare degraded case where session
            issuance failed after the account was created — clients fall back
            to their verify-then-log-in path.
          content:
            application/json:
              schema:
                type: object
                required: [message, userId, user, verificationEmailSent]
                properties:
                  message:
                    type: string
                  userId:
                    type: string
                    format: uuid
                  verificationEmailSent:
                    type: boolean
                    description: >-
                      False when the verification email could not be sent —
                      clients should point the user at resend-verification.
                  user:
                    type: object
                    required: [id, email, isEmailVerified]
                    properties:
                      id:
                        type: string
                        format: uuid
                      email:
                        type: string
                        format: email
                      name:
                        type: [string, 'null']
                      isEmailVerified:
                        type: boolean
                  sessionToken:
                    type: string
                  expiresAt:
                    type: string
                    format: date-time
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Signup disabled (SIGNUP_DISABLED) or user already exists (USER_EXISTS)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/verify-email:
    post:
      tags: [auth]
      summary: Verify email address
      operationId: verifyEmail
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyEmailRequest'
      responses:
        '200':
          description: Email verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '400':
          description: Invalid or expired token (INVALID_TOKEN)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/resend-verification:
    post:
      tags: [auth]
      summary: Resend the email-verification link for the current user
      description: >-
        No-op (still 200) when the email is already verified. Requires an
        authenticated session — registration auto-logs users in, so this is
        the recovery path for a lost verification email.
      operationId: resendVerification
      responses:
        '200':
          description: Verification email sent (or already verified)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Cooldown — a verification email was sent less than a minute ago (RATE_LIMITED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/forgot-password:
    post:
      tags: [auth]
      summary: Request a password reset email
      description: Always returns success to prevent user enumeration.
      operationId: forgotPassword
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ForgotPasswordRequest'
      responses:
        '200':
          description: Reset email sent (or silently skipped if email not found)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '429':
          description: Too many requests (rate limited)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/reset-password:
    post:
      tags: [auth]
      summary: Reset password using a token
      description: Token expires after 1 hour.
      operationId: resetPassword
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordRequest'
      responses:
        '200':
          description: Password reset successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '400':
          description: Invalid or expired token (INVALID_TOKEN)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/change-password:
    post:
      tags: [auth]
      summary: Change password (authenticated)
      operationId: changePassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePasswordRequest'
      responses:
        '200':
          description: Password changed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '400':
          description: Current password incorrect
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/verify-password:
    post:
      tags: [auth]
      summary: Re-verify password and refresh step-up freshness (authenticated)
      description: >-
        Re-verifies the current account password and stamps the CURRENT session as
        recently authenticated (reauthAt) without minting a new session. Required
        before sensitive actions such as app-password creation, which enforce a
        server-side step-up freshness window (#1353).
      operationId: verifyPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyPasswordRequest'
      responses:
        '200':
          description: Password verified; session marked recently authenticated
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified:
                    type: boolean
        '403':
          description: Password incorrect (INVALID_PASSWORD)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/change-email:
    post:
      tags: [auth]
      summary: Request an email change (authenticated)
      description: >-
        Verifies the current password and sends a confirmation link to the new address.
        The change only takes effect once the link is opened via verify-email-change.
      operationId: changeEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangeEmailRequest'
      responses:
        '200':
          description: Verification sent to the new address
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '400':
          description: New email matches current email (SAME_EMAIL)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Password incorrect (INVALID_PASSWORD) or unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Email address not available (EMAIL_IN_USE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/verify-email-change:
    post:
      tags: [auth]
      summary: Confirm a pending email change
      description: Token expires after 24 hours. On success all sessions are invalidated.
      operationId: verifyEmailChange
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyEmailChangeRequest'
      responses:
        '200':
          description: Email updated; re-authentication required
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '400':
          description: Invalid or expired token (INVALID_TOKEN, TOKEN_EXPIRED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Email address no longer available (EMAIL_IN_USE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/change-name:
    post:
      tags: [auth]
      summary: Change the signed-in user's display name
      description: >-
        Was set once at registration and never exposed on any update path
        since. No password/verification step — a display name isn't a
        security credential.
      operationId: changeName
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangeNameRequest'
      responses:
        '200':
          description: Name updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/login:
    post:
      tags: [auth]
      summary: Log in and create a session
      operationId: loginUser
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Login successful — session cookie is set
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '401':
          description: Invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/webauthn/register/options:
    post:
      tags: [auth]
      summary: Begin passkey registration (authenticated)
      description: Returns WebAuthn PublicKeyCredentialCreationOptions plus an opaque flowId to echo back on verify.
      operationId: webauthnRegisterOptions
      responses:
        '200':
          description: Registration options
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebauthnOptionsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/webauthn/register/verify:
    post:
      tags: [auth]
      summary: Finish passkey registration (authenticated)
      operationId: webauthnRegisterVerify
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebauthnRegisterVerifyRequest'
      responses:
        '201':
          description: Passkey registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified:
                    type: boolean
        '400':
          description: Challenge invalid/expired or verification failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/webauthn/login/options:
    post:
      tags: [auth]
      summary: Begin passwordless login
      description: Returns WebAuthn PublicKeyCredentialRequestOptions plus an opaque flowId. Email is optional (usernameless supported).
      operationId: webauthnLoginOptions
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebauthnLoginOptionsRequest'
      responses:
        '200':
          description: Authentication options
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebauthnOptionsResponse'

  /api/v1/auth/webauthn/login/verify:
    post:
      tags: [auth]
      summary: Finish passwordless login
      operationId: webauthnLoginVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebauthnLoginVerifyRequest'
      responses:
        '200':
          description: Login successful — session cookie is set
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '401':
          description: Passkey not recognized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/webauthn/credentials:
    get:
      tags: [auth]
      summary: List the caller's own passkeys
      operationId: webauthnListCredentials
      responses:
        '200':
          description: Stored passkeys
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebauthnCredentialsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/webauthn/credentials/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    patch:
      tags: [auth]
      summary: Rename a passkey
      operationId: webauthnRenameCredential
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebauthnCredentialRenameRequest'
      responses:
        '200':
          description: Renamed
          content:
            application/json:
              schema:
                type: object
                properties:
                  renamed:
                    type: boolean
        '404':
          description: Not found (or not owned by the caller)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [auth]
      summary: Revoke a passkey
      description: >-
        Requires a first-party session with a recent re-authentication
        (POST /auth/verify-password within the last 5 minutes) — see #1353's
        step-up gate. An ambient app-password or stale session gets 403.
      operationId: webauthnRevokeCredential
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked:
                    type: boolean
        '403':
          description: Step-up re-authentication required, or not a first-party session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found (or not owned by the caller)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/oauth/providers:
    get:
      tags: [auth]
      summary: List enabled social sign-in providers
      operationId: listOauthProviders
      security: []
      responses:
        '200':
          description: Configured providers
          content:
            application/json:
              schema:
                type: object
                properties:
                  providers:
                    type: array
                    items:
                      type: string
                      enum: [google, microsoft, apple, discord]

  /api/v1/auth/oauth/{provider}/start:
    get:
      tags: [auth]
      summary: Begin social sign-in — redirects to the provider
      description: >-
        Redirects (302) the browser to the provider's authorization page. On the
        provider callback the backend completes sign-in and redirects back to the
        web app (cookie set) or, for the native client, to a single-use exchange
        code delivered via the `taptidy://auth/callback` custom-scheme deep link
        or the verified `https://taptidy.app/auth/callback` App Link (#1853),
        depending on `ANDROID_OAUTH_CALLBACK_URL`. For `client=android`,
        `code_challenge` (PKCE, S256, RFC 7636) is required (#1853) — the deep
        link callback is interceptable by another app, so the exchange code can
        only be redeemed by the app instance holding the matching `code_verifier`.
      operationId: oauthStart
      security: []
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
            enum: [google, microsoft, apple, discord]
        - name: client
          in: query
          required: false
          schema:
            type: string
            enum: [web, android]
            default: web
        - name: code_challenge
          in: query
          required: false
          description: >-
            PKCE S256 code challenge (RFC 7636), base64url, 43-128 chars. Required
            when `client=android` (#1853); ignored for `client=web`.
          schema:
            type: string
      responses:
        '302':
          description: Redirect to the provider authorization endpoint
        '400':
          description: 'client=android with a missing/malformed code_challenge (#1853)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Unknown provider
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Provider not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/oauth/{provider}/callback:
    get:
      tags: [auth]
      summary: Provider OAuth callback — completes sign-in
      description: >-
        Provider redirects here with an authorization code. The backend exchanges
        it, resolves the account (account-takeover-safe hybrid linking), creates a
        session, then redirects back to the originating client. Errors redirect
        with an `error` query parameter rather than returning a body.
      operationId: oauthCallback
      security: []
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
            enum: [google, microsoft, apple, discord]
        - name: code
          in: query
          required: false
          schema: { type: string }
        - name: state
          in: query
          required: false
          schema: { type: string }
      responses:
        '302':
          description: Redirect to the web app (cookie set) or native deep link
    post:
      tags: [auth]
      summary: Provider OAuth callback (form_post — Apple)
      operationId: oauthCallbackFormPost
      security: []
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
            enum: [google, microsoft, apple, discord]
      requestBody:
        required: false
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                code: { type: string }
                state: { type: string }
      responses:
        '302':
          description: Redirect to the web app (cookie set) or native deep link

  /api/v1/auth/oauth/exchange:
    post:
      tags: [auth]
      summary: Redeem a native one-time OAuth code for a session
      description: >-
        Native clients receive a single-use code via the taptidy://auth/callback
        deep link (or the verified https App Link, #1853) and exchange it here
        (over HTTPS) for the session token, so the raw token never travels in the
        deep link. `codeVerifier` (PKCE, RFC 7636) is required (#1853) and must
        match the `code_challenge` sent to `/oauth/{provider}/start` — this is
        the control that stops another app which intercepted the callback from
        redeeming a code it did not originate.
      operationId: oauthExchange
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, codeVerifier]
              properties:
                code: { type: string }
                codeVerifier:
                  type: string
                  description: PKCE code verifier (RFC 7636) matching the code_challenge sent to /start.
      responses:
        '200':
          description: Session issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PasswordlessLoginResponse'
        '400':
          description: Missing code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Invalid or expired code, or missing/mismatched codeVerifier (#1853)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/otp/request:
    post:
      tags: [auth]
      summary: Request a one-time email sign-in code
      operationId: otpRequest
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: Always returns ok (does not reveal whether the email is registered)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResponse'

  /api/v1/auth/otp/verify:
    post:
      tags: [auth]
      summary: Verify an email sign-in code and start a session
      operationId: otpVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email:
                  type: string
                  format: email
                code:
                  type: string
                  pattern: '^\d{6}$'
      responses:
        '200':
          description: Sign-in successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PasswordlessLoginResponse'
        '401':
          description: Invalid or expired code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/magic-link/request:
    post:
      tags: [auth]
      summary: Request a passwordless magic-link sign-in email
      operationId: magicLinkRequest
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: Always returns ok (does not reveal whether the email is registered)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResponse'

  /api/v1/auth/magic-link/verify:
    post:
      tags: [auth]
      summary: Verify a magic-link token and start a session
      operationId: magicLinkVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, token]
              properties:
                email:
                  type: string
                  format: email
                token:
                  type: string
      responses:
        '200':
          description: Sign-in successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PasswordlessLoginResponse'
        '401':
          description: Invalid or expired link
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/logout:
    post:
      tags: [auth]
      summary: Log out and invalidate the current session
      operationId: logoutUser
      responses:
        '200':
          description: Logged out
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/me:
    get:
      tags: [auth]
      summary: Get current authenticated user
      operationId: getCurrentUser
      responses:
        '200':
          description: Current user profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/auth/refresh:
    post:
      tags: [auth]
      summary: Refresh session token
      description: |
        Refreshes the session sliding window. Returns refreshed: true only when
        the session had fewer than 7 days remaining; otherwise extends silently.
      operationId: refreshSession
      responses:
        '200':
          description: Session refreshed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefreshResponse'
        '401':
          description: Session expired or invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/csrf-token:
    get:
      tags: [auth]
      summary: Get CSRF token
      description: |
        Returns a CSRF token to be included as the `X-CSRF-Token` header on all
        mutating requests (POST, PATCH, DELETE). Also sets a `csrf_token` cookie
        (not httpOnly) so browser clients can read it and apply the double-submit
        cookie pattern.

        This endpoint does not require an active session; it may be called before
        login to pre-warm the CSRF cookie. The token is valid for 24 hours.
      operationId: getCsrfToken
      security: []
      responses:
        '200':
          description: CSRF token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CsrfTokenResponse'

  # =========================================================================
  # Debug / developer tools (not available in production)
  # =========================================================================
  /api/debug/whoami:
    get:
      tags: [debug]
      summary: Debug — current auth identity
      x-internal: true
      description: |
        **Development only.** Returns the authenticated user's identity as resolved
        by the server. Only available when the `TAPTIDY_DEV_AUTH` environment variable
        is set to `1`. Returns HTTP 404 in all other environments.

        Used by the Android client for connectivity and auth-mode diagnostics during
        development. Not part of the stable public API.
      operationId: getWhoami
      responses:
        '200':
          description: Current auth identity
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WhoamiResponse'
        '401':
          description: Not authenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Endpoint disabled — TAPTIDY_DEV_AUTH is not set to '1'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Routines endpoints
  # =========================================================================
  /api/v2/routines:
    get:
      tags: [routines]
      summary: List routine templates (v2)
      operationId: listRoutinesV2
      responses:
        '200':
          description: Routine templates
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RoutineTemplateV2'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Feature not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [routines]
      summary: Create routine template (v2)
      operationId: createRoutineV2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineTemplateV2Create'
      responses:
        '201':
          description: Routine template created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineTemplateV2'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/preview:
    post:
      tags: [routines]
      summary: Preview recurrence expansion (v2)
      operationId: previewRoutineRecurrenceV2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutinePreviewRequest'
      responses:
        '200':
          description: Recurrence preview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutinePreviewResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/reorder:
    put:
      tags: [routines]
      summary: Reorder routine templates (v2)
      operationId: reorderRoutinesV2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineReorderRequest'
      responses:
        '200':
          description: Reorder applied
          content:
            application/json:
              schema:
                type: object
                required: [orderedIds]
                properties:
                  orderedIds:
                    type: array
                    items:
                      type: string
                      format: uuid
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/today:
    get:
      tags: [routines]
      summary: Get today's routine actions (v2)
      operationId: getRoutinesTodayV2
      responses:
        '200':
          description: Today's routine occurrences
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/calendar:
    get:
      tags: [routines]
      summary: Get routine calendar rollups bucketed by user timezone (v2)
      operationId: getRoutineCalendarV2
      parameters:
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date
            example: '2026-06-01'
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date
            example: '2026-06-30'
      responses:
        '200':
          description: Per-day routine completion rollups
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineCalendarResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden (routine feature entitlement required)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/stats:
    get:
      tags: [routines]
      summary: Get aggregate routine metrics (v2)
      operationId: getRoutineStatsV2
      parameters:
        - name: startDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: endDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Routine metrics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineV2StatsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/timeline:
    get:
      tags: [routines]
      summary: Get unified timeline (routines + tasks + timer sessions) (v2)
      operationId: getRoutineTimelineV2
      parameters:
        - name: startDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: endDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          required: false
          schema:
            type: integer
        - name: cursor
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Unified timeline events
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineV2TimelineResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/{id}:
    get:
      tags: [routines]
      summary: Get routine template details (v2)
      operationId: getRoutineV2
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Routine template details
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      tags: [routines]
      summary: Update routine template (v2)
      operationId: updateRoutineV2
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineTemplateV2Update'
      responses:
        '200':
          description: Updated routine template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineTemplateV2'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [routines]
      summary: Delete routine template (v2)
      operationId: deleteRoutineV2
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/{id}/occurrences:
    get:
      tags: [routines]
      summary: List routine occurrences (v2)
      operationId: listRoutineOccurrencesV2
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: startDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: endDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Occurrences
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  additionalProperties: true
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/{templateId}/log:
    post:
      tags: [routines]
      summary: Log flexible routine completion
      description: Log a completion for a flexible-frequency routine (creates occurrence + event in one step)
      operationId: logFlexibleRoutineCompletionV2
      parameters:
        - name: templateId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineFlexibleLogRequest'
      responses:
        '200':
          description: Completion logged with period progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  event:
                    type: object
                    additionalProperties: true
                  periodProgress:
                    $ref: '#/components/schemas/FrequencyGoalProgress'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/{id}/complete:
    post:
      tags: [routines]
      summary: Complete routine occurrence (v2)
      operationId: completeRoutineOccurrenceV2
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineOccurrenceCompleteRequest'
      responses:
        '200':
          description: Completion logged
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/{id}/skip:
    post:
      tags: [routines]
      summary: Skip routine occurrence (v2)
      operationId: skipRoutineOccurrenceV2
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineOccurrenceSkipRequest'
      responses:
        '200':
          description: Skip logged
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v2/routines/{id}/snooze:
    post:
      tags: [routines]
      summary: Snooze routine occurrence (v2)
      operationId: snoozeRoutineOccurrenceV2
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineOccurrenceSnoozeRequest'
      responses:
        '200':
          description: Snooze logged
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Views endpoints
  # =========================================================================
  /api/v1/views:
    get:
      tags: [views]
      summary: List custom views
      description: Returns views ordered by system views first, then user-defined order.
      operationId: listViews
      responses:
        '200':
          description: View list
          content:
            application/json:
              schema:
                type: object
                required: [views]
                properties:
                  views:
                    type: array
                    items:
                      $ref: '#/components/schemas/CustomView'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [views]
      summary: Create a custom view
      operationId: createView
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ViewCreate'
      responses:
        '201':
          description: View created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomView'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/views/{id}:
    put:
      tags: [views]
      summary: Update a custom view
      description: Returns 403 when attempting to update a system view.
      operationId: updateView
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ViewCreate'
      responses:
        '200':
          description: Updated view
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomView'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Cannot modify a system view
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: View not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [views]
      summary: Delete a custom view
      description: Returns 403 when attempting to delete a system view.
      operationId: deleteView
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: View deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Cannot delete a system view
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: View not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Conflicts endpoints
  # =========================================================================
  /api/v1/conflicts:
    get:
      tags: [conflicts]
      summary: List unresolved sync conflicts
      operationId: listConflicts
      responses:
        '200':
          description: Conflict list
          content:
            application/json:
              schema:
                type: object
                required: [conflicts]
                properties:
                  conflicts:
                    type: array
                    items:
                      $ref: '#/components/schemas/SyncConflict'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/conflicts/history:
    get:
      tags: [conflicts]
      summary: List resolved/dismissed conflicts (incl. auto-resolved history)
      description: |
        Returns recently resolved or dismissed conflicts as history (#1339).
        Auto-resolutions stash the discarded side, so each item carries a
        `canUndo` flag indicating whether the resolution can be reverted.
      operationId: listConflictHistory
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: Resolved/dismissed conflict history
          content:
            application/json:
              schema:
                type: object
                required: [conflicts]
                properties:
                  conflicts:
                    type: array
                    items:
                      $ref: '#/components/schemas/ResolvedConflict'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/conflicts/{conflictId}/undo:
    post:
      tags: [conflicts]
      summary: Undo an auto-resolution
      description: |
        Reverts an auto-resolved conflict by applying the discarded side back to
        the canonical entity and flagging it for push (#1339).
      operationId: undoConflictResolution
      parameters:
        - name: conflictId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Resolution undone
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  entityId:
                    type: string
        '400':
          description: Not undoable (not resolved, or no discarded data)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Conflict not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Entity gone or name collision on revert
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/conflicts/bulk-resolve:
    post:
      tags: [conflicts]
      summary: Resolve many sync conflicts with one strategy
      description: |
        Applies a single resolution strategy (local or remote) to all matching
        unresolved conflicts. Optionally filtered by entityType or an explicit
        conflictIds list. Conflicts whose entity update hits a duplicate-name
        constraint are reported as failures; conflicts whose local entity no
        longer exists are dismissed and counted as skipped.
      operationId: bulkResolveConflicts
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConflictBulkResolveRequest'
      responses:
        '200':
          description: Bulk resolution outcome
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConflictBulkResolveResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/conflicts/{conflictId}/resolve:
    post:
      tags: [conflicts]
      summary: Resolve a sync conflict
      description: |
        Resolves the conflict by choosing local, remote, or manual data.
        On resolution the winning entity's syncVersion is incremented and
        needsSync is set to true so it propagates on the next sync cycle.
      operationId: resolveConflict
      parameters:
        - name: conflictId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConflictResolveRequest'
      responses:
        '200':
          description: Conflict resolved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConflictResolveResponse'
        '400':
          description: Validation error or missing resolvedData for manual resolution
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Conflict not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Settings endpoints
  # =========================================================================
  /api/v1/settings:
    get:
      tags: [settings]
      summary: Get user settings
      operationId: getSettings
      responses:
        '200':
          description: User settings (typed columns merged with legacy JSON fields)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSettings'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags: [settings]
      summary: Update user settings
      operationId: updateSettings
      parameters:
        - name: If-Match
          in: header
          required: false
          description: >-
            Optimistic-concurrency precondition: the settingsRevision the client
            last read, as an HTTP entity-tag. Surrounding quotes are accepted and
            stripped, so both `5` and `"5"` are valid. A value that is not a
            decimal revision is rejected with 400 INVALID_IF_MATCH rather than
            being ignored. When supplied and stale, the write is rejected with
            409 rather than overwriting another device's change. When omitted,
            the write applies last-write-wins.
          schema:
            type: string
            pattern: '^"?\d+"?$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserSettingsUpdate'
      responses:
        '200':
          description: Settings updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSettings'
        '400':
          description: >-
            Validation error, or INVALID_IF_MATCH when the If-Match header is
            present but is not a decimal revision.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            The supplied If-Match revision is stale. Re-read the settings and
            reapply the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SettingsConflictResponse'

  /api/v1/settings/device:
    get:
      tags: [settings]
      summary: Get non-roaming settings for this device
      operationId: getDevicePreferences
      parameters:
        - name: X-Taptidy-Device-Id
          in: header
          required: true
          schema:
            type: string
            minLength: 8
            maxLength: 128
      responses:
        '200':
          description: Settings for the supplied device only
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DevicePreferencesResponse'
        '400':
          description: Missing or invalid device identifier
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags: [settings]
      summary: Update non-roaming settings for this device
      description: >-
        Merges per namespace. Top-level groups the request omits are preserved;
        `notifications`, `integrations` and `performance` are shallow-merged; and
        `widgets` merges by widget id, so sending one widget's values leaves the
        others intact. The merge runs in a serializable transaction, so a
        concurrent write is retried rather than lost.
      operationId: updateDevicePreferences
      parameters:
        - name: X-Taptidy-Device-Id
          in: header
          required: true
          schema:
            type: string
            minLength: 8
            maxLength: 128
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DevicePreferencesUpdate'
      responses:
        '200':
          description: Device settings updated with newest-change-wins semantics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DevicePreferencesResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/settings/app-passwords:
    get:
      tags: [settings]
      summary: List app passwords
      operationId: listAppPasswords
      responses:
        '200':
          description: App password list (plaintext passwords never returned)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/AppPassword'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [settings]
      summary: Create an app password
      description: The plaintext password is returned only in this response and never again.
      operationId: createAppPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AppPasswordCreate'
      responses:
        '201':
          description: App password created — save the password now
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppPasswordCreateResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/settings/app-passwords/{id}:
    delete:
      tags: [settings]
      summary: Revoke an app password
      operationId: deleteAppPassword
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: App password revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: App password not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/settings/personal-access-tokens:
    get:
      tags: [settings]
      summary: List personal access tokens
      description: Active (non-revoked) personal access tokens. Token hashes are never returned.
      operationId: listPersonalAccessTokens
      responses:
        '200':
          description: Personal access token list (plaintext tokens never returned)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PersonalAccessToken'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [settings]
      summary: Create a personal access token
      description: The plaintext Bearer token (taptidy_pat_<id>.<secret>) is returned only in this response and never again.
      operationId: createPersonalAccessToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonalAccessTokenCreate'
      responses:
        '200':
          description: Personal access token created — save the token now
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonalAccessTokenCreateResponse'
        '400':
          description: Validation failed (missing name, unknown scope, or expiresAt not a future date within the 365-day ceiling)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Recent re-authentication required (STEP_UP_REQUIRED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/settings/personal-access-tokens/{id}:
    delete:
      tags: [settings]
      summary: Revoke a personal access token
      description: Soft-revoke — the token immediately stops authenticating.
      operationId: deletePersonalAccessToken
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Personal access token revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Email settings endpoints
  # =========================================================================
  /api/v1/email-settings:
    get:
      tags: [email-settings]
      summary: Get email/SMTP configuration
      description: SMTP password is never returned — smtpPasswordSet indicates whether one is configured.
      operationId: getEmailSettings
      responses:
        '200':
          description: Email settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailSettings'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags: [email-settings]
      summary: Update email/SMTP configuration
      operationId: updateEmailSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailSettingsUpdate'
      responses:
        '200':
          description: Settings saved
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      tags: [email-settings]
      summary: Update email/SMTP configuration (PUT alias of PATCH)
      operationId: replaceEmailSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailSettingsUpdate'
      responses:
        '200':
          description: Settings saved
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
        '400':
          description: Invalid input (for example DOMAIN_NOT_ALLOWED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/email-settings/test:
    post:
      tags: [email-settings]
      summary: Send a test email
      operationId: testEmailSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailTestRequest'
      responses:
        '200':
          description: Test email result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailTestResponse'
        '400':
          description: SMTP delivery failure (SEND_FAILED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Inbound Email Handle endpoints
  # =========================================================================

  /api/v1/email-handles:
    post:
      tags: [email-handles]
      summary: Claim an inbound email handle
      description: |
        Claims a unique inbound email handle (e.g. `alice` -> `alice@my.taptidy.app`).
        One handle per user. Sends a verification email on success.
      operationId: claimEmailHandle
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handle]
              properties:
                handle:
                  type: string
                  minLength: 3
                  maxLength: 30
                  pattern: '^(?!.*\.\.)[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$'
                  example: alice
      responses:
        '201':
          description: Handle claimed — verification email sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailHandle'
        '400':
          description: Invalid or reserved handle
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Handle already taken or user already has a handle
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/email-handles/resend:
    post:
      tags: [email-handles]
      summary: Resend handle verification email
      operationId: resendEmailHandleVerification
      responses:
        '200':
          description: Verification resent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailHandleResendResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No pending handle
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: RESEND_RATE_LIMITED
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/email-handles/change:
    post:
      tags: [email-handles]
      summary: Request handle change
      description: Creates a PENDING handle claim and sends verification; current ACTIVE handle remains active until verification.
      operationId: changeEmailHandle
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailHandleChangeRequest'
      responses:
        '201':
          description: Change requested
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailHandle'
        '400':
          description: Invalid or reserved handle
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: HANDLE_NOT_AVAILABLE or HANDLE_PENDING
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: HANDLE_COOLDOWN
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/email-handles/me:
    get:
      tags: [email-handles]
      summary: Get current user's inbound email handle
      operationId: getMyEmailHandle
      responses:
        '200':
          description: |
            Current handle. When the user has not claimed one, handle is null and
            the id/status/inboundAddress/createdAt fields are omitted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailHandle'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: |
            Legacy: older servers returned 404 NO_HANDLE when no handle was
            claimed. The current API returns 200 with handle:null instead, but
            this response is retained so clients still handle it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/email-handles/verify:
    post:
      tags: [email-handles]
      summary: Verify email handle via token
      description: |
        Activates a PENDING handle using the plaintext token from the verification email.
        No authentication required — the token is the proof of ownership.
        CSRF-exempt.
      operationId: verifyEmailHandle
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Handle verified and activated
          content:
            application/json:
              schema:
                type: object
                required: [success, handle]
                properties:
                  success:
                    type: boolean
                  handle:
                    type: string
        '400':
          description: TOKEN_INVALID or TOKEN_EXPIRED
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Email webhooks (CloudMailin)
  # =========================================================================
  /api/v1/webhooks/email:
    post:
      tags: [webhooks]
      summary: Canonical email webhook endpoint (all providers)
      description: |
        CSRF-exempt endpoint that receives inbound email routing payloads and
        email event webhooks from any configured provider.

        Active provider selected via EMAIL_INBOUND_PROVIDER env var.
        Supported values: forwardemail | brevo | postal | cloudmailin

        Each provider uses its own signature verification mechanism:
        - forwardemail: X-Webhook-Signature (HMAC-SHA256, sha256=<hex>)
        - brevo: X-Brevo-Signature (HMAC-SHA256, enterprise plans only)
        - postal: X-Postal-Signature (RSA-SHA1, base64)
        - cloudmailin: Authorization: Basic (user:pass)

        Dev bypass available when NODE_ENV is not 'production' AND TAPTIDY_DEV_AUTH=1 AND ALLOW_INBOUND_NO_SIGNATURE=1.
      operationId: handleEmailWebhook
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              # anyOf, not oneOf. The provider schemas below are permissive by
              # necessity — each provider posts several payload shapes to this
              # one endpoint and most carry no discriminator property — so a
              # real payload matches more than one branch and `oneOf`
              # (exactly-one) could never be satisfied. Dispatch is done at
              # runtime by identifyWebhookKind(), not by schema validation; the
              # branches document the accepted shapes.
              anyOf:
                - $ref: '#/components/schemas/ForwardEmailInboundPayload'
                - $ref: '#/components/schemas/ForwardEmailEventPayload'
                - $ref: '#/components/schemas/ForwardEmailBouncePayload'
                - $ref: '#/components/schemas/BrevoInboundPayload'
                - $ref: '#/components/schemas/BrevoEventPayload'
                - $ref: '#/components/schemas/PostalInboundPayload'
                - $ref: '#/components/schemas/PostalEventPayload'
                - $ref: '#/components/schemas/CloudMailinInboundPayload'
                - $ref: '#/components/schemas/CloudMailinEventPayload'
      responses:
        '200':
          description: Webhook received and processed
          content:
            application/json:
              schema:
                type: object
                required: [received]
                properties:
                  received:
                    type: boolean
                  kind:
                    type: string
                    enum: [inbound, event]
                  inserted:
                    type: boolean
                    description: Whether a new record was inserted (false for duplicates)
                  warning:
                    type: string
                    description: Present only if a non-fatal error occurred
        '400':
          description: Unable to identify webhook payload type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Webhook authentication/signature verification failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Webhook secrets not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Legacy endpoint - deprecated, kept for backwards compatibility during migration
  /api/v1/inbound/email/resend:
    post:
      tags: [inbound-email]
      summary: (DEPRECATED) Legacy inbound email webhook
      description: |
        DEPRECATED: Use /api/v1/webhooks/email instead.
        This endpoint is kept for backwards compatibility during provider migrations.
      operationId: receiveInboundEmailLegacy
      security: []
      deprecated: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
      responses:
        '200':
          description: Webhook received
          content:
            application/json:
              schema:
                type: object
                properties:
                  received:
                    type: boolean

  /api/v1/webhooks/inbound/task:
    post:
      tags: [webhooks]
      summary: Create a task from an inbound webhook (Home Assistant & automations)
      description: |
        CSRF-exempt endpoint for server-to-server automation triggers (e.g. Home
        Assistant appliance events) to create tasks. Creation routes through the
        canonical task-creation flow, so webhook-created tasks behave exactly
        like tasks created via POST /api/v1/tasks.

        Authenticate with a personal access token (Bearer `taptidy_pat_<id>.<secret>`)
        or an app password (Basic auth); the credential must carry the
        `tasks:write` scope. Cookie-session authentication is rejected
        (403 SESSION_AUTH_NOT_ALLOWED) — browsers cannot set Authorization
        headers cross-site, so only header-based credentials are accepted here.
      operationId: handleInboundTaskWebhook
      security:
        - patAuth: []
        - basicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboundTaskWebhookRequest'
      responses:
        '201':
          description: Task created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Validation failed (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Authentication required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden — missing tasks:write scope (INSUFFICIENT_SCOPE), cookie-session auth not allowed (SESSION_AUTH_NOT_ALLOWED), userId mismatch (FORBIDDEN), or usage limit reached (LIMIT_REACHED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: Supplied projectId does not exist or belongs to another user (PROJECT_NOT_FOUND)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Task creation failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Push notification endpoints
  # =========================================================================
  /api/v1/notifications/vapid-public-key:
    get:
      tags: [notifications]
      summary: Get VAPID public key for Web Push
      description: Public endpoint — no authentication required.
      operationId: getVapidPublicKey
      security: []
      responses:
        '200':
          description: VAPID public key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VapidPublicKeyResponse'
        '404':
          description: VAPID push notifications not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/notifications/subscribe:
    post:
      tags: [notifications]
      summary: Subscribe to push notifications
      operationId: subscribePushNotifications
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PushSubscribeRequest'
      responses:
        '201':
          description: Subscription registered
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/notifications/unsubscribe:
    delete:
      tags: [notifications]
      summary: Unsubscribe from push notifications
      operationId: unsubscribePushNotifications
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PushUnsubscribeRequest'
      responses:
        '200':
          description: Subscription removed
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Sync endpoints
  # =========================================================================
  /api/v1/sync/status:
    get:
      tags: [sync]
      summary: Get current sync status
      description: Returns outbox queue summary and per-provider sync health.
      operationId: getSyncStatus
      responses:
        '200':
          description: Sync status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncStatusResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sync/run:
    post:
      tags: [sync]
      summary: Trigger a manual sync cycle
      operationId: runSync
      responses:
        '200':
          description: Sync completed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncRunResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sync/drain:
    post:
      tags: [sync]
      summary: Drain the outbox queue
      description: Processes all pending outbox operations immediately.
      operationId: drainOutbox
      responses:
        '200':
          description: Drain result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OutboxDrainResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sync/dead-letters:
    get:
      tags: [sync]
      summary: List this user's dead-lettered outbox operations
      description: >-
        #2170 itemized counterpart to the aggregate per-provider deadLetterCount on
        /api/v1/sync/status. Returns each 'failed'/'dead' outbox operation owned by
        the caller, including the entity snapshot payload, so the client can inspect,
        rebase, or individually retry a permanent push failure. Capped at the 100
        most recently updated — `truncated` is true when more rows exist beyond the
        cap. Requires the integrations entitlement, same as the retry endpoints.
      operationId: listDeadLetters
      responses:
        '200':
          description: Dead-lettered operations
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncDeadLetterListResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Integrations entitlement required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sync/dead-letters/retry:
    post:
      tags: [sync]
      summary: Retry this user's dead-lettered outbox operations
      description: Requeues outbox operations stuck in permanent 'dead' status for another attempt, optionally scoped to one provider (e.g. a Todoist task whose push permanently failed after a lost project mapping).
      operationId: retryDeadLetters
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                providerId:
                  # Not $ref: '#/components/schemas/ProviderType' — that shared enum is
                  # missing 'google_calendar' (pre-existing drift, also affects
                  # ProviderResponse.type; tracked as follow-up, not fixed here since it
                  # ripples into every ProviderType consumer on both platforms). This
                  # endpoint's actual accepted values match the backend's
                  # ProviderTypeSchema (z.enum(['todoist','caldav','google_calendar'])).
                  type: string
                  enum: [todoist, caldav, google_calendar]
                  description: Retry only this provider's dead-lettered operations. Omit to retry across all providers.
      responses:
        '200':
          description: Retry result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RetryDeadLettersResponse'
        '400':
          description: Invalid providerId
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limited (global API limiter) — back off before retrying
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sync/dead-letters/{id}/retry:
    post:
      tags: [sync]
      summary: Retry a single dead-lettered outbox operation
      description: >-
        #2170 per-item counterpart to POST /api/v1/sync/dead-letters/retry. Requeues
        one 'failed'/'dead' operation owned by the caller with the same state reset
        (status=pending, retryCount=0, deadAt/lastError cleared). A missing, foreign,
        or no-longer-dead-lettered id all return the same 404 — no existence leakage.
      operationId: retryDeadLetter
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Retry result — retried is 1 on success.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RetryDeadLettersResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Integrations entitlement required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such dead-lettered operation for this user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limited (global API limiter) — back off before retrying
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sync/dead-letters/{id}:
    delete:
      tags: [sync]
      summary: Permanently discard a single dead-lettered outbox operation
      description: >-
        Discards one 'failed'/'dead' operation owned by the caller without
        retrying it. A missing, foreign, or no-longer-dead-lettered id all
        return the same 404 — no existence leakage.
      operationId: dismissDeadLetter
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Dismiss result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DismissDeadLetterResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Integrations entitlement required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No such dead-lettered operation for this user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limited (global API limiter) — back off before retrying
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Admin endpoints
  # =========================================================================
  /api/v1/admin/settings:
    get:
      tags: [admin]
      summary: List all system settings
      operationId: listAdminSettings
      responses:
        '200':
          description: System settings map
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings:
                    type: object
                    additionalProperties: {}
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/settings/{key}:
    put:
      tags: [admin]
      summary: Update a system setting
      operationId: updateAdminSetting
      parameters:
        - name: key
          in: path
          required: true
          schema:
            type: string
          example: signup_enabled
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdminSettingUpdateRequest'
      responses:
        '200':
          description: Setting updated
          content:
            application/json:
              schema:
                type: object
                required: [setting]
                properties:
                  setting:
                    $ref: '#/components/schemas/AdminSetting'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/reseed:
    post:
      tags: [admin]
      summary: Reseed sync state for a user/provider pair
      operationId: adminSyncReseed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, providerId]
              properties:
                userId:
                  type: string
                  format: uuid
                providerId:
                  type: string
      responses:
        '200':
          description: Reseed result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  result:
                    type: object
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/detect-wipe:
    post:
      tags: [admin]
      summary: Detect provider wipe for a user
      operationId: adminSyncDetectWipe
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, providerId]
              properties:
                userId:
                  type: string
                  format: uuid
                providerId:
                  type: string
      responses:
        '200':
          description: Wipe detection result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/outbox/stats:
    get:
      tags: [admin]
      summary: Get outbox queue statistics
      operationId: adminGetOutboxStats
      responses:
        '200':
          description: Outbox statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdminOutboxStats'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/provider-health:
    get:
      tags: [admin]
      summary: Get provider health status
      operationId: adminGetProviderHealth
      responses:
        '200':
          description: Provider health list
          content:
            application/json:
              schema:
                type: object
                required: [providers]
                properties:
                  providers:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProviderHealth'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/outbox/coalesce:
    post:
      tags: [admin]
      summary: Coalesce outbox operations
      description: Merges redundant outbox entries to reduce queue depth.
      operationId: adminCoalesceOutbox
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                userId:
                  type: string
                  format: uuid
                  description: Scope coalescing to a specific user (omit for global)
      responses:
        '200':
          description: Coalesce result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  coalescedCount:
                    type: integer
                  deletedCount:
                    type: integer
                  keptCount:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/outbox/dead-letter:
    get:
      tags: [admin]
      summary: List dead-letter outbox operations
      operationId: adminListDeadLetter
      responses:
        '200':
          description: Dead-letter operations
          content:
            application/json:
              schema:
                type: object
                required: [operations]
                properties:
                  operations:
                    type: array
                    items:
                      type: object
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/outbox/dead-letter/{id}/retry:
    post:
      tags: [admin]
      summary: Retry a single dead-letter operation
      operationId: adminRetryDeadLetter
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Retry queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/outbox/dead-letter/retry-all:
    post:
      tags: [admin]
      summary: Retry all dead-letter operations
      operationId: adminRetryAllDeadLetter
      responses:
        '200':
          description: Bulk retry result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  count:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/provider/{providerId}/pause:
    post:
      tags: [admin]
      summary: Pause sync for a specific provider
      operationId: adminPauseProvider
      parameters:
        - name: providerId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId:
                  type: string
                  format: uuid
      responses:
        '200':
          description: Provider paused
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/admin/sync/provider/{providerId}/resume:
    post:
      tags: [admin]
      summary: Resume sync for a specific provider
      operationId: adminResumeProvider
      parameters:
        - name: providerId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId:
                  type: string
                  format: uuid
      responses:
        '200':
          description: Provider resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin role required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Sections and Filters endpoints
  # =========================================================================
  /api/v1/sections:
    get:
      tags: [sections]
      summary: List sections for user's projects
      operationId: listSections
      parameters:
        - name: since
          in: query
          description: Return sections updated since this timestamp (ms since epoch)
          schema:
            type: integer
      responses:
        '200':
          description: Section list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Section'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/filters:
    get:
      tags: [filters]
      summary: List saved filters
      operationId: listFilters
      parameters:
        - name: since
          in: query
          description: Return user-authored filters updated since this timestamp (ms since epoch). Smart lists are always evaluated and returned.
          schema:
            type: integer
        - name: x-timezone
          in: header
          description: IANA timezone used for due-date sensitive smart-list evaluation (defaults to UTC)
          schema:
            type: string
      responses:
        '200':
          description: Saved filter list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SavedFilter'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Timer endpoints (task-scoped)
  # =========================================================================
  /api/v1/tasks/{taskId}/timer/start:
    post:
      tags: [timers]
      summary: Start a Pomodoro timer session
      description: Creates a 25-minute (1500 second) Pomodoro session for the task.
      operationId: startTimer
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '201':
          description: Timer session started
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TimerSession'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{taskId}/timer/complete:
    post:
      tags: [timers]
      summary: Complete a timer session
      operationId: completeTimer
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimerSessionActionRequest'
      responses:
        '200':
          description: Session completed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TimerSession'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task or session not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{taskId}/timer/pause:
    post:
      tags: [timers]
      summary: Pause an active timer session
      operationId: pauseTimer
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimerSessionActionRequest'
      responses:
        '200':
          description: Session paused
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TimerSession'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task or session not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{taskId}/timer/resume:
    post:
      tags: [timers]
      summary: Resume a paused timer session
      operationId: resumeTimer
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimerSessionActionRequest'
      responses:
        '200':
          description: Session resumed — response includes accumulated totalPausedSeconds
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TimerSession'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task or session not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{taskId}/timer/log:
    post:
      tags: [timers]
      summary: Retroactively log a manual time entry
      description: Creates a completed manual_log session without going through start/complete flow.
      operationId: logTimer
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TimerLogRequest'
      responses:
        '201':
          description: Manual session logged
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TimerSession'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/tasks/{taskId}/timer/sessions:
    get:
      tags: [timers]
      summary: List timer sessions for a task
      operationId: listTimerSessions
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Timer session list
          content:
            application/json:
              schema:
                type: object
                required: [sessions]
                properties:
                  sessions:
                    type: array
                    items:
                      $ref: '#/components/schemas/TimerSession'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Journal endpoints (task-scoped)
  # =========================================================================
  /api/v1/tasks/{taskId}/journal:
    get:
      tags: [journals]
      summary: List journal entries for a task
      operationId: listJournalEntries
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Journal entries
          content:
            application/json:
              schema:
                type: object
                required: [entries]
                properties:
                  entries:
                    type: array
                    items:
                      $ref: '#/components/schemas/JournalEntry'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [journals]
      summary: Add a journal entry to a task
      operationId: createJournalEntry
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JournalCreateRequest'
      responses:
        '201':
          description: Journal entry created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JournalEntry'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Task Comments endpoints (Pro tier)
  # =========================================================================
  /api/v1/tasks/{taskId}/comments:
    get:
      tags: [comments]
      summary: List comments for a task
      description: Returns all comments on a task. Requires Pro tier.
      operationId: listTaskComments
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Task comments
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskCommentList'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Pro tier required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [comments]
      summary: Add a comment to a task
      description: Creates a new comment on a task. Requires Pro tier.
      operationId: createTaskComment
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskCommentCreateRequest'
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskComment'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Pro tier required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Activity endpoints
  # =========================================================================
  /api/v1/tasks/{taskId}/activity:
    get:
      tags: [activity]
      summary: Get activity history for a task
      description: Returns the 50 most recent activity entries for the task.
      operationId: getTaskActivity
      parameters:
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Task activity log
          content:
            application/json:
              schema:
                type: object
                required: [activities]
                properties:
                  activities:
                    type: array
                    items:
                      $ref: '#/components/schemas/TaskActivityEntry'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/activity/feed:
    get:
      tags: [activity]
      summary: Get global activity feed
      description: Returns the 100 most recent activity entries across all entities for the authenticated user.
      operationId: getActivityFeed
      responses:
        '200':
          description: Global activity feed
          content:
            application/json:
              schema:
                type: object
                required: [activities]
                properties:
                  activities:
                    type: array
                    items:
                      $ref: '#/components/schemas/GlobalActivityEntry'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Quick capture endpoint
  # =========================================================================
  /api/v1/tasks/quick-capture:
    post:
      tags: [tasks]
      summary: Quick-capture a completed task
      description: |
        Creates a task and immediately marks it as completed in one step.
        Useful for logging work done retroactively. If timeSpentMinutes is
        provided, a manual_log timer session is also created.
      operationId: quickCaptureTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QuickCaptureRequest'
      responses:
        '201':
          description: Task created and marked complete
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Suggestions endpoint
  # =========================================================================
  /api/v1/suggestions:
    get:
      tags: [suggestions]
      summary: Get suggested tasks
      description: Returns up to 50 incomplete tasks ranked by due date, priority, and completion patterns.
      operationId: getSuggestions
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
      responses:
        '200':
          description: Suggested tasks
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskListResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Import endpoint
  # =========================================================================
  /api/v1/import:
    post:
      tags: [import]
      summary: Import tasks from an external source or file
      description: |
        Supports multiple import formats via the `format` discriminator field:
        - `todoist_api` — Import directly from Todoist via API token
        - `csv` — Generic CSV with user-defined column mapping
        - `markdown` — Obsidian/Notion-style checklist Markdown
        - `todoist_csv` — Todoist web export CSV
        - `json` — TapTidy JSON backup (restore)

        Returns an ImportResult with counts and per-row errors. Partial failures
        are allowed — bad rows are skipped and reported, valid rows are saved.
      operationId: importTasks
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ImportRequest'
      responses:
        '200':
          description: Import result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/safety-score:
    get:
      tags: [safety]
      summary: Task Safety Score
      description: |
        Returns a conservative, explainable safety score summarizing sync,
        backup, conflicts, export readiness, privacy configuration, and reminder health.
      operationId: getSafetyScore
      responses:
        '200':
          description: Safety score report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SafetyScoreResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden — entitlement denied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/health/sovereignty:
    get:
      tags: [health]
      summary: Sovereignty Report
      description: |
        Returns a per-user report of telemetry and data-sharing feature status.
        Useful for verifying privacy configuration in-app.
      operationId: getSovereigntyReport
      responses:
        '200':
          description: Sovereignty report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SovereigntyResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden — entitlement denied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/export:
    post:
      tags: [export]
      summary: Export tasks to a file
      description: |
        Exports the user's tasks in the requested format:
        - `csv` — Spreadsheet-compatible CSV
        - `markdown` — Obsidian/Notion-compatible Markdown checklists
        - `json` — Full TapTidy backup (suitable for restore via /api/v1/backup/import)
        - `passport` — Portable Task Passport: human-readable + machine-readable bundle

        Supports optional filtering by project, completion status, and date range.
        Response is a file download with `Content-Disposition: attachment`.
      operationId: exportTasks
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportRequest'
      responses:
        '200':
          description: File download
          headers:
            Content-Disposition:
              schema:
                type: string
                example: attachment; filename="taptidy-export-2026-04-07.csv"
          content:
            text/csv:
              schema:
                type: string
            text/markdown:
              schema:
                type: string
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/BackupExportResponse'
                  - $ref: '#/components/schemas/TaskPassport'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Phase 3: Backup / Export endpoints
  # =========================================================================
  /api/v1/backup/export:
    get:
      tags: [backup]
      summary: Export all user data
      description: Returns a full snapshot of the user's tasks, projects, and tags for backup or migration.
      operationId: exportBackup
      responses:
        '200':
          description: Full data export
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BackupExportResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/backup/import:
    post:
      tags: [backup]
      summary: Import data from a backup
      description: |
        Imports tasks, projects, and tags from a previously exported TapTidy JSON backup.
        Projects and tags are upserted by name; tasks are created as new records.
      operationId: importBackup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BackupImportRequest'
      responses:
        '200':
          description: Import preview result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BackupImportResponse'
        '400':
          description: Invalid backup data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ===========================================================================
  # PR3: Capture review engine endpoints
  # ===========================================================================

  /api/v1/captures/summary:
    get:
      tags: [captures]
      summary: Get capture review summary
      description: |
        Returns summary counts and eligibility data for the Catch Up / Inbox Review view.
        Eligibility is computed server-side based on age, reviewAfter date, and classification rules.
      operationId: getCaptureSummary
      responses:
        '200':
          description: Capture summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureSummary'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/compose/options:
    get:
      tags: [captures]
      summary: Get capture composer routing options
      description: |
        Returns server-authoritative composer settings used for auto-routing decisions.
      operationId: getCaptureComposeOptions
      responses:
        '200':
          description: Capture composer options
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureComposeOptionsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/compose:
    post:
      tags: [captures]
      summary: Compose and route freeform capture input
      description: |
        Accepts freeform text, runs classifier scoring, then routes to TASK, CAPTURE, or IGNORED.
        The server is authoritative for final routing.
      operationId: composeCapture
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ComposeCaptureRequest'
      responses:
        '201':
          description: Capture composition result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ComposeCaptureResponse'
        '400':
          description: Invalid input
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CaptureTextEmptyError'
                  - $ref: '#/components/schemas/CaptureTextTooLongError'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Compose failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureComposeFailedError'

  /api/v1/captures:
    get:
      tags: [captures]
      summary: List captures
      description: |
        Returns a paginated list of captures. Supports filtering by status, classification, source,
        and sort order. IGNORE-classified captures are excluded by default.
      operationId: listCaptures
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: pageSize
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: status
          in: query
          description: Comma-separated list of statuses (INBOX, CONVERTED, ARCHIVED)
          schema:
            type: string
            example: INBOX
        - name: classification
          in: query
          description: Comma-separated list of classifications (TASK, INBOX, INFO, IGNORE)
          schema:
            type: string
            example: INBOX,INFO
        - name: source
          in: query
          description: Comma-separated list of sources (EMAIL, UI, API)
          schema:
            type: string
        - name: sortBy
          in: query
          schema:
            type: string
            enum: [oldest, newest, confidence_asc, confidence_desc]
            default: oldest
        - name: includeIgnored
          in: query
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: Paginated capture list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureListResult'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/{id}/archive:
    post:
      tags: [captures]
      summary: Archive a capture
      description: |
        Sets capture status to ARCHIVED and records archivedAt timestamp.
        CSRF-protected (session cookie required).
      operationId: archiveCapture
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Capture archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureActionResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden (capture belongs to another user)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/{id}/defer:
    post:
      tags: [captures]
      summary: Defer capture review
      description: |
        Sets reviewAfter to now + N days, removing it from the eligible list until then.
        If days is omitted, uses the user's captureReviewSnoozeDaysDefault setting (default 7).
        CSRF-protected (session cookie required).
      operationId: deferCapture
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CaptureDeferRequest'
      responses:
        '200':
          description: Capture deferred
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureActionResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/{id}/convert-to-task:
    post:
      tags: [captures]
      summary: Convert capture to task
      description: |
        Creates a new TaptidyTask from this capture's content, then sets the capture
        status to CONVERTED and records the resulting task ID.
        CSRF-protected (session cookie required).
      operationId: convertCaptureToTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Capture converted to task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureActionResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Capture already converted or archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/batch-convert:
    post:
      tags: [captures]
      summary: Batch convert strong suggestions to tasks
      description: |
        Creates new tasks from the specified captures, marks them as CONVERTED,
        and returns a batchId that can be used to undo the operation.
        CSRF-protected (session cookie required).
      operationId: batchConvertCaptures
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchConvertRequest'
      responses:
        '200':
          description: Captures batch converted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchConvertResponse'
        '400':
          description: Bad request (e.g. invalid array of ids)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/batch-convert/undo:
    post:
      tags: [captures]
      summary: Undo a batch convert operation
      description: |
        Restores captures from a previous batch convert back to INBOX status,
        and deletes the tasks that were created during that batch convert.
        CSRF-protected (session cookie required).
      operationId: undoBatchConvert
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchConvertUndoRequest'
      responses:
        '200':
          description: Batch convert undone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchConvertUndoResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs:
    get:
      tags: [captures]
      summary: List cleanup runs
      operationId: listCleanupRuns
      responses:
        '200':
          description: Cleanup run list
          content:
            application/json:
              schema:
                type: object
                required: [runs]
                properties:
                  runs:
                    type: array
                    items:
                      $ref: '#/components/schemas/CleanupRun'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [captures]
      summary: Create a cleanup run snapshot
      operationId: createCleanupRun
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CleanupRunCreateRequest'
      responses:
        '201':
          description: Cleanup run created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunWithItems'
        '400':
          description: No eligible inbox items or invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/run/{runId}:
    get:
      tags: [captures]
      summary: Get cleanup run detail
      operationId: getCleanupRun
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Cleanup run with items and token metadata
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunWithItems'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Cleanup run not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/run/{runId}/items/{itemId}:
    patch:
      tags: [captures]
      summary: Update cleanup run item decision
      operationId: updateCleanupRunItemDecision
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: itemId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CleanupRunItemDecisionRequest'
      responses:
        '200':
          description: Decision updated
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Run or item not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Run already applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/run/{runId}/apply:
    post:
      tags: [captures]
      summary: Apply accepted cleanup run decisions
      operationId: applyCleanupRun
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Cleanup run applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunApplyResult'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Run not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Run already applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/run/{runId}/undo:
    post:
      tags: [captures]
      summary: Undo a previously applied cleanup run
      operationId: undoCleanupRun
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Cleanup run undone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunUndoResult'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Run not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Run is not applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/run/{runId}/tokens:
    post:
      tags: [captures]
      summary: Create scoped cleanup run token
      operationId: createCleanupRunToken
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CleanupRunTokenCreateRequest'
      responses:
        '201':
          description: Token created (raw token shown once)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunTokenCreateResponse'
        '400':
          description: Invalid or empty scopes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Run not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/run/{runId}/tokens/{tokenId}:
    delete:
      tags: [captures]
      summary: Revoke cleanup run token
      operationId: revokeCleanupRunToken
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: tokenId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Token revoked
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Token not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/external-run/{runId}:
    get:
      tags: [captures]
      summary: External read of cleanup run via scoped token
      operationId: externalGetCleanupRun
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: x-cleanup-run-token
          in: header
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Cleanup run detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunWithItems'
        '401':
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing scope or token expired/revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/external-run/{runId}/decisions:
    post:
      tags: [captures]
      summary: External bulk decision update via scoped token
      operationId: externalUpdateCleanupRunDecisions
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: x-cleanup-run-token
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CleanupRunExternalDecisionBatchRequest'
      responses:
        '200':
          description: Decisions updated
          content:
            application/json:
              schema:
                type: object
                required: [success, updated]
                properties:
                  success:
                    type: boolean
                  updated:
                    type: integer
        '400':
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing scope or token expired/revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/external-run/{runId}/apply:
    post:
      tags: [captures]
      summary: External apply of cleanup run via scoped token
      operationId: externalApplyCleanupRun
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: x-cleanup-run-token
          in: header
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Cleanup run applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunApplyResult'
        '401':
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing scope or token expired/revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/cleanup-runs/external-run/{runId}/undo:
    post:
      tags: [captures]
      summary: External undo of cleanup run via scoped token
      operationId: externalUndoCleanupRun
      parameters:
        - name: runId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: x-cleanup-run-token
          in: header
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Cleanup run undone
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanupRunUndoResult'
        '401':
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Missing scope or token expired/revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # -------------------------------------------------------------------------
  # Capture triage — unified inbox item model
  # (introduced in the Android capture-to-triage flow, commit 05bcc3d)
  # -------------------------------------------------------------------------
  /api/v1/captures/items:
    get:
      tags: [captures]
      summary: List unified inbox items
      description: |
        Returns a paginated, filterable list of inbox items using the unified
        `InboxItem` model. Unlike `GET /api/v1/captures` (which returns the legacy
        `CaptureItem` model with a flat status field), this endpoint exposes the
        richer lifecycle model with `lifecycleState`, `itemType`, `title`, and
        `notes`.

        Designed for the Android capture-to-triage flow.
      operationId: listInboxItems
      parameters:
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
        - in: query
          name: pageSize
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - in: query
          name: sortBy
          schema:
            type: string
            enum: [oldest, newest]
            default: oldest
        - in: query
          name: itemType
          description: Filter by item type
          schema:
            $ref: '#/components/schemas/ItemType'
        - in: query
          name: lifecycleState
          description: Filter by lifecycle state
          schema:
            $ref: '#/components/schemas/LifecycleState'
        - in: query
          name: projectId
          description: Filter to items belonging to a specific project
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Paginated list of inbox items
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboxItemsListResult'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/batch-transition:
    post:
      tags: [captures]
      summary: Batch-transition inbox items
      description: |
        Applies the same lifecycle action to multiple inbox items in a single
        request. Items that cannot be transitioned (e.g. already in an incompatible
        state) are reported in the `failures` array without blocking the rest of
        the batch.
      operationId: batchTransitionCaptureItems
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CaptureBatchTransitionRequest'
      responses:
        '200':
          description: Batch transition result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureBatchTransitionResult'
        '400':
          description: Bad request (e.g. empty itemIds)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/{id}:
    patch:
      tags: [captures]
      summary: Update an inbox item
      description: |
        Updates the `title` and/or `notes` of an existing inbox item. Both fields
        are optional — supply only the fields you want to change.
      operationId: updateCaptureItem
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CaptureItemUpdateRequest'
      responses:
        '200':
          description: Updated inbox item
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboxItem'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden — item belongs to another user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Item not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/captures/{id}/transition:
    post:
      tags: [captures]
      summary: Transition an inbox item
      description: |
        Applies a lifecycle action to a single inbox item, driving it through the
        `LifecycleState` state machine.

        **Allowed transitions:**
        - `RAW_INBOX` → `ACTIVE` / `SCHEDULED` / `SNOOZED` / `ARCHIVED` / `DELETED`
        - `SNOOZED` → `RAW_INBOX` (via `RESTORE_TO_INBOX`)
        - `ACTIVE` / `SCHEDULED` → `COMPLETED` / `ARCHIVED`
        - `ARCHIVED` → `RAW_INBOX` (via `RESTORE_TO_INBOX`), or reclassified via
          `MOVE_TO_SOMEDAY` / `CONVERT_TO_NOTE` / `CONVERT_TO_REFERENCE`
        - `ARCHIVED` → `ACTIVE` / `SCHEDULED` (via `PROMOTE_TO_TASK`)
      operationId: transitionCaptureItem
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CaptureTransitionRequest'
      responses:
        '200':
          description: Transition applied successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaptureTransitionResult'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden — item belongs to another user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Item not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Invalid lifecycle transition (item is in an incompatible state)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # QOL-20: Diagnostics endpoint
  # =========================================================================
  /api/v1/diagnostics/sync-health:
    get:
      tags: [diagnostics]
      summary: Get sync health diagnostics
      description: |
        Returns diagnostics information for sync workers, battery optimization state,
        and OEM-specific guidance. Useful for support and troubleshooting.
      operationId: getSyncHealthDiagnostics
      responses:
        '200':
          description: Sync health diagnostics
          content:
            application/json:
              schema:
                type: object
                properties:
                  lastWorkerRun:
                    oneOf:
                      - type: string
                        format: date-time
                      - type: 'null'
                  lastWorkerDelay:
                    oneOf:
                      - type: integer
                      - type: 'null'
                    description: Last worker delay in milliseconds
                  deviceManufacturer:
                    oneOf:
                      - type: string
                      - type: 'null'
                  batteryOptimizationState:
                    oneOf:
                      - type: string
                        enum: [unknown, optimized, unrestricted, restricted]
                      - type: 'null'
                  oemGuidance:
                    oneOf:
                      - type: object
                        properties:
                          manufacturer:
                            type: string
                          message:
                            type: string
                          helpUrl:
                            type: string
                      - type: 'null'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # QOL-21: Presence endpoints
  # =========================================================================
  /api/v1/presence/task/{taskId}:
    get:
      tags: [shares]
      summary: Get presence info for a task
      description: |
        Returns real-time presence information for a task, including active users
        and edit lock status for collision detection.
      operationId: getTaskPresence
      parameters:
        - in: path
          name: taskId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Presence information
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PresenceInfo'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # ESO Bridge — internal service-to-service API (vsa.im ↔ TapTidy)
  # =========================================================================
  /api/internal/eso-bridge/status:
    get:
      tags: [eso-bridge]
      summary: Resolve bridge user status
      description: |
        Resolves (or auto-provisions) the TapTidy user for the incoming E-SO email.
        Bridge calls are idempotent by email and always return `linked: true` after resolve.
      operationId: esoBridgeStatus
      security:
        - esoBridgeKey: []
      parameters:
        - name: x-eso-user-email
          in: header
          required: true
          schema:
            type: string
            format: email
          description: Google-authenticated email from the E-SO session
      responses:
        '200':
          description: Resolved bridge user status
          content:
            application/json:
              schema:
                type: object
                required: [linked, email, userId]
                properties:
                  linked:
                    type: boolean
                  email:
                    type: string
                    format: email
                  userId:
                    type: string
                    format: uuid
        '400':
          description: Missing or invalid x-eso-user-email header
        '401':
          description: Invalid or missing bridge key
        '503':
          description: ESO_BRIDGE_SECRET not configured

  /api/internal/eso-bridge/tasks:
    get:
      tags: [eso-bridge]
      summary: Fetch active tasks for resolved user
      description: |
        Resolves/provisions the user by email and returns active tasks
        in the canonical TapTidy task list response shape.
      operationId: esoBridgeGetTasks
      security:
        - esoBridgeKey: []
      parameters:
        - name: x-eso-user-email
          in: header
          required: true
          schema:
            type: string
            format: email
      responses:
        '200':
          description: Task list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskListResponse'
        '400':
          description: Missing or invalid x-eso-user-email header
        '401':
          description: Invalid or missing bridge key
        '503':
          description: ESO_BRIDGE_SECRET not configured
    post:
      tags: [eso-bridge]
      summary: Create a task for resolved user
      description: |
        Resolves/provisions the user by email and creates a TapTidy task.
        Created tasks are marked with source `eso-bridge`.
      operationId: esoBridgeCreateTask
      security:
        - esoBridgeKey: []
      parameters:
        - name: x-eso-user-email
          in: header
          required: true
          schema:
            type: string
            format: email
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  maxLength: 500
                description:
                  type: string
                  maxLength: 10000
                dueDate:
                  type: string
                  format: date-time
                priority:
                  type: integer
                  minimum: 0
                  maximum: 4
      responses:
        '201':
          description: Task created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Validation error
        '401':
          description: Invalid or missing bridge key
        '503':
          description: ESO_BRIDGE_SECRET not configured

  /api/internal/eso-bridge/tasks/{taskId}:
    patch:
      tags: [eso-bridge]
      summary: Update a task for resolved bridge user
      description: |
        Resolves/provisions the user by email, then partially updates an owned task.
        Returns 404 when the task is missing or not owned by the resolved user.
      operationId: esoBridgeUpdateTask
      security:
        - esoBridgeKey: []
      parameters:
        - name: x-eso-user-email
          in: header
          required: true
          schema:
            type: string
            format: email
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 500
                description:
                  type: string
                  maxLength: 10000
                dueDate:
                  type: [string, 'null']
                  format: date-time
                priority:
                  type: integer
                  minimum: 0
                  maximum: 4
      responses:
        '200':
          description: Updated task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400':
          description: Validation error
        '401':
          description: Invalid or missing bridge key
        '404':
          description: Task not found or not owned by resolved user
        '503':
          description: ESO_BRIDGE_SECRET not configured
    delete:
      tags: [eso-bridge]
      summary: Delete a task for resolved bridge user
      description: |
        Resolves/provisions the user by email, then deletes an owned task.
      operationId: esoBridgeDeleteTask
      security:
        - esoBridgeKey: []
      parameters:
        - name: x-eso-user-email
          in: header
          required: true
          schema:
            type: string
            format: email
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Task deleted
          content:
            application/json:
              schema:
                type: object
                required: [deleted]
                properties:
                  deleted:
                    type: boolean
        '401':
          description: Invalid or missing bridge key
        '404':
          description: Task not found or not owned by resolved user
        '503':
          description: ESO_BRIDGE_SECRET not configured

  /api/internal/eso-bridge/tasks/{taskId}/complete:
    patch:
      tags: [eso-bridge]
      summary: Mark a task as complete
      description: |
        Resolves/provisions the user by email, then marks an owned task complete.
        Returns the canonical task response.
      operationId: esoBridgeCompleteTask
      security:
        - esoBridgeKey: []
      parameters:
        - name: x-eso-user-email
          in: header
          required: true
          schema:
            type: string
            format: email
        - name: taskId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Task completed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '401':
          description: Invalid or missing bridge key
        '404':
          description: Task not found or not owned by resolved user
        '503':
          description: ESO_BRIDGE_SECRET not configured
  # =========================================================================
  # AI — task breakdown, telemetry, providers
  # =========================================================================
  /api/v1/ai/task-breakdown:
    post:
      tags: [ai]
      summary: Generate AI subtask suggestions
      description: |
        Analyzes a task and suggests actionable subtasks using AI.
        Supports multiple providers (on-device, OpenAI, Gemini, etc.).
        Falls back to rule-based suggestions if AI is unavailable.
      operationId: generateTaskBreakdown
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskBreakdownRequest'
      responses:
        '200':
          description: Suggestions generated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskBreakdownResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded

  /api/v1/ai/economy-setup:
    post:
      tags: [ai]
      summary: Suggest a starter chore + reward catalog for the given child ages
      description: |
        #1723 AI Smart Ledger. Reuses the per-user aiProvider resolution. When the
        resolved provider is on_device (the current reality — cloud providers are
        disabled), returns a DETERMINISTIC age-bucketed catalog. usedFallback flags
        a downgrade from a selected-but-disabled cloud provider.
      operationId: aiEconomySetup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [childAges]
              properties:
                childAges:
                  type: array
                  maxItems: 20
                  items:
                    type: integer
                    minimum: 0
                    maximum: 21
      responses:
        '200':
          description: Suggested chores and rewards
          content:
            application/json:
              schema:
                type: object
                required: [chores, rewards, usedFallback, provider]
                properties:
                  chores:
                    type: array
                    items:
                      type: object
                      required: [title, description, effortWeight, taskTier]
                      properties:
                        title:
                          type: string
                        description:
                          type: string
                        effortWeight:
                          type: integer
                          minimum: 1
                          maximum: 3
                        taskTier:
                          type: string
                          enum: [baseline, hustle]
                  rewards:
                    type: array
                    items:
                      type: object
                      required: [title, pointCost]
                      properties:
                        title:
                          type: string
                        pointCost:
                          type: integer
                  usedFallback:
                    type: boolean
                  provider:
                    type: string
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/ai/telemetry:
    post:
      tags: [ai]
      summary: Log AI telemetry event
      description: |
        Records AI feature usage for analytics and improvement.
        Best-effort — failures don't affect user experience.
      operationId: logAiTelemetry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AiTelemetryEvent'
      responses:
        '201':
          description: Event logged
        '400':
          description: Invalid event data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/ai/providers:
    get:
      tags: [ai]
      summary: List available AI providers
      description: Returns all AI providers and their enabled status
      operationId: listAiProviders
      responses:
        '200':
          description: Provider list
          content:
            application/json:
              schema:
                type: object
                required: [providers]
                properties:
                  providers:
                    type: array
                    items:
                      type: object
                      required: [id, enabled]
                      properties:
                        id:
                          type: string
                        enabled:
                          type: boolean

  # =========================================================================
  # Analytics events + A/B experiments (#1463)
  # =========================================================================
  /api/v1/telemetry:
    post:
      tags: [analytics]
      summary: Record a product analytics event
      description: |
        Consent-gated, best-effort event recording (task completion, view switches,
        AI feedback, ...). Accepted but not stored when the analytics entitlement is off.
      operationId: recordAnalyticsEvent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnalyticsEvent'
      responses:
        '201':
          description: Event recorded
        '202':
          description: Accepted but not recorded (analytics disabled) or best-effort failure
        '400':
          description: Invalid event
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/experiments:
    get:
      tags: [analytics]
      summary: Get the current user's experiment assignments
      description: Returns a sticky variant per running experiment for the authenticated user.
      operationId: getExperimentAssignments
      responses:
        '200':
          description: Assignments
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExperimentAssignmentsResponse'
    post:
      tags: [analytics]
      summary: Create an experiment (admin)
      operationId: createExperiment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExperimentCreateRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [experiment]
                properties:
                  experiment:
                    $ref: '#/components/schemas/Experiment'
        '400':
          description: Invalid experiment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Admin privileges required
        '409':
          description: Experiment key already exists

  /api/v1/experiments/all:
    get:
      tags: [analytics]
      summary: List all experiments (admin)
      operationId: listExperiments
      responses:
        '200':
          description: Experiment registry
          content:
            application/json:
              schema:
                type: object
                required: [experiments]
                properties:
                  experiments:
                    type: array
                    items:
                      $ref: '#/components/schemas/Experiment'
        '403':
          description: Admin privileges required

  /api/v1/experiments/{id}:
    patch:
      tags: [analytics]
      summary: Update an experiment (admin)
      operationId: updateExperiment
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExperimentUpdateRequest'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [experiment]
                properties:
                  experiment:
                    $ref: '#/components/schemas/Experiment'
        '400':
          description: Invalid update
        '403':
          description: Admin privileges required
        '404':
          description: Experiment not found

  # =========================================================================
  # Shopping — categorized items, batch ops, sharing, real-time sync
  # =========================================================================
  /api/v1/projects/{id}/shopping-items:
    get:
      tags: [shopping]
      summary: Get categorized shopping items
      description: |
        Returns all shopping items for a project, grouped by grocery category.
        Categories are ordered by typical store layout.
      operationId: getShoppingItems
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Shopping items retrieved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShoppingItemsResponse'
        '404':
          description: Project not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/projects/{id}/shopping-items/batch:
    post:
      tags: [shopping]
      summary: Batch add shopping items
      description: |
        Adds multiple shopping items with auto-categorization.
        Supports natural language items like "2x milk" or "1 lb chicken".
      operationId: batchAddShoppingItems
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items:
                    type: string
                  description: List of item names
      responses:
        '201':
          description: Items created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShoppingBatchResponse'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/projects/{id}/shopping-reset:
    post:
      tags: [shopping]
      summary: Reset shopping list
      description: Clears completed items or unchecks all items.
      operationId: resetShoppingList
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                clearCompleted:
                  type: boolean
                  default: true
                  description: If true, delete completed. If false, uncheck them.
      responses:
        '200':
          description: Reset successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShoppingResetResponse'

  /api/v1/projects/{id}/shopping-share:
    post:
      tags: [shopping]
      summary: Share shopping list
      description: Share a shopping list with another user for real-time collaboration.
      operationId: shareShoppingList
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                role:
                  type: string
                  enum: [viewer, editor]
                  default: editor
      responses:
        '201':
          description: Shared successfully
        '403':
          description: Not authorized
        '404':
          description: User not found
        '409':
          description: Already shared

  /api/v1/projects/{id}/shopping-collaborators:
    get:
      tags: [shopping]
      summary: Get shopping list collaborators
      operationId: getShoppingCollaborators
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Collaborators
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShoppingCollaboratorsResponse'

  /api/v1/projects/{id}/shopping-sync:
    post:
      tags: [shopping]
      summary: Sync shopping list update
      description: Broadcasts a change to all collaborators via WebSocket.
      operationId: syncShoppingList
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [itemId, action]
              properties:
                itemId:
                  type: string
                action:
                  type: string
                  enum: [toggle, delete, add]
                data:
                  type: object
      responses:
        '200':
          description: Sync broadcasted

  # =========================================================================
  # Notes — standalone notes CRUD
  # =========================================================================
  /api/v1/notes:
    get:
      tags: [notes]
      summary: List notes
      operationId: listNotes
      parameters:
        - in: query
          name: page
          schema:
            type: integer
            default: 1
        - in: query
          name: pageSize
          schema:
            type: integer
            default: 50
            maximum: 100
        - in: query
          name: sortBy
          schema:
            type: string
            enum: [newest, oldest]
            default: newest
        - in: query
          name: projectId
          schema:
            type: string
            format: uuid
        - in: query
          name: tag
          schema:
            type: string
      responses:
        '200':
          description: Paginated notes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NoteListResponse'
    post:
      tags: [notes]
      summary: Create a note
      operationId: createNote
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoteCreate'
      responses:
        '201':
          description: Note created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NoteResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/notes/{id}:
    patch:
      tags: [notes]
      summary: Update a note
      operationId: updateNote
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NoteUpdate'
      responses:
        '200':
          description: Note updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NoteResponse'
        '404':
          description: Note not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [notes]
      summary: Delete a note
      operationId: deleteNote
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Note deleted
        '404':
          description: Note not found

  # =========================================================================
  # Library — note category management
  # =========================================================================
  /api/v1/library/categories:
    get:
      tags: [library]
      summary: List note categories
      operationId: listCategories
      responses:
        '200':
          description: Categories
          content:
            application/json:
              schema:
                type: object
                required: [categories]
                properties:
                  categories:
                    type: array
                    items:
                      $ref: '#/components/schemas/CategoryInfo'
    post:
      tags: [library]
      summary: Create a category
      operationId: createCategory
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryCreate'
      responses:
        '201':
          description: Category created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryInfo'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Category already exists

  /api/v1/library/categories/{name}:
    patch:
      tags: [library]
      summary: Update a category
      operationId: updateCategory
      parameters:
        - in: path
          name: name
          required: true
          schema:
            type: string
          description: URI-encoded category name
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryUpdate'
      responses:
        '200':
          description: Category updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryInfo'
        '404':
          description: Category not found
    delete:
      tags: [library]
      summary: Delete a category
      operationId: deleteCategory
      parameters:
        - in: path
          name: name
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Category deleted
        '404':
          description: Category not found

  # =========================================================================
  # Routines V2 — templates, occurrences, completion, stats
  # =========================================================================
  /api/v1/routines:
    get:
      tags: [routines]
      summary: List routine templates
      operationId: listRoutines
      responses:
        '200':
          description: Routine templates
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RoutineTemplate'
    post:
      tags: [routines]
      summary: Create a routine template
      operationId: createRoutine
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineTemplateCreate'
      responses:
        '201':
          description: Routine created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineTemplate'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/routines/preview:
    post:
      tags: [routines]
      summary: Preview recurrence occurrences
      operationId: previewRecurrence
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recurrenceRule, startDate]
              properties:
                recurrenceRule:
                  type: string
                startDate:
                  type: string
                  format: date-time
                endDate:
                  type: string
                  format: date-time
                maxOccurrences:
                  type: integer
                  minimum: 1
                  maximum: 365
      responses:
        '200':
          description: Occurrence preview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecurrencePreviewResponse'

  /api/v1/routines/reorder:
    put:
      tags: [routines]
      summary: Reorder routine templates
      operationId: reorderRoutines
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orderedIds]
              properties:
                orderedIds:
                  type: array
                  items:
                    type: string
      responses:
        '200':
          description: Reorder applied

  /api/v1/routines/today:
    get:
      tags: [routines]
      summary: Get today's routine occurrences
      operationId: getRoutinesToday
      responses:
        '200':
          description: Today's occurrences with summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutinesTodayResponse'

  /api/v1/routines/stats:
    get:
      tags: [routines]
      summary: Get routine statistics
      operationId: getRoutineStats
      parameters:
        - in: query
          name: startDate
          schema:
            type: string
            format: date-time
        - in: query
          name: endDate
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Routine stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineStatsResponse'

  /api/v1/routines/timeline:
    get:
      tags: [routines]
      summary: Get routine timeline events
      operationId: getRoutineTimeline
      parameters:
        - in: query
          name: startDate
          schema:
            type: string
            format: date-time
        - in: query
          name: endDate
          schema:
            type: string
            format: date-time
        - in: query
          name: limit
          schema:
            type: integer
            default: 50
            maximum: 200
        - in: query
          name: cursor
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Timeline events
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineTimelineResponse'

  /api/v1/routines/{id}:
    get:
      tags: [routines]
      summary: Get a routine template with stats
      operationId: getRoutine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Routine template with stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineTemplate'
        '404':
          description: Routine not found
    put:
      tags: [routines]
      summary: Update a routine template
      operationId: updateRoutine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoutineTemplateUpdate'
      responses:
        '200':
          description: Routine updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineTemplate'
        '404':
          description: Routine not found
    delete:
      tags: [routines]
      summary: Delete a routine template
      operationId: deleteRoutine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Routine deleted
        '404':
          description: Routine not found

  /api/v1/routines/{id}/occurrences:
    get:
      tags: [routines]
      summary: Get occurrences for a routine
      operationId: getRoutineOccurrences
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
        - in: query
          name: startDate
          schema:
            type: string
            format: date-time
        - in: query
          name: endDate
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Occurrences
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RoutineOccurrence'

  /api/v1/routines/{id}/log:
    post:
      tags: [routines]
      summary: Log flexible routine completion
      operationId: logRoutine
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: Template ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [minutesLogged]
              properties:
                minutesLogged:
                  type: number
                  minimum: 0
                  maximum: 480
                notes:
                  type: string
                  maxLength: 1000
      responses:
        '200':
          description: Logged
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineLogResponse'

  /api/v1/routines/{id}/complete:
    post:
      tags: [routines]
      summary: Complete a routine occurrence
      operationId: completeRoutineOccurrence
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: Occurrence ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [minutesLogged]
              properties:
                minutesLogged:
                  type: number
                  minimum: 0
                  maximum: 480
                notes:
                  type: string
                  maxLength: 1000
      responses:
        '200':
          description: Occurrence completed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineOccurrenceActionResponse'

  /api/v1/routines/{id}/skip:
    post:
      tags: [routines]
      summary: Skip a routine occurrence
      operationId: skipRoutineOccurrence
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: Occurrence ID
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: Occurrence skipped
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineOccurrenceActionResponse'

  /api/v1/routines/{id}/snooze:
    post:
      tags: [routines]
      summary: Snooze a routine occurrence
      operationId: snoozeRoutineOccurrence
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: Occurrence ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [until]
              properties:
                until:
                  type: string
                  format: date-time
      responses:
        '200':
          description: Occurrence snoozed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineOccurrenceActionResponse'

  # =========================================================================
  # Nudges — contextual nudge delivery and dismissal
  # =========================================================================
  /api/v1/nudges:
    get:
      tags: [nudges]
      summary: Get active nudges
      description: Returns up to 2 contextual nudges. Free tier receives only overdue_digest.
      operationId: getNudges
      responses:
        '200':
          description: Active nudges
          content:
            application/json:
              schema:
                type: object
                required: [nudges]
                properties:
                  nudges:
                    type: array
                    maxItems: 2
                    items:
                      $ref: '#/components/schemas/Nudge'

  /api/v1/nudges/dismiss:
    post:
      tags: [nudges]
      summary: Dismiss a nudge
      operationId: dismissNudge
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nudgeId, action]
              properties:
                nudgeId:
                  type: string
                  maxLength: 200
                action:
                  type: string
                  enum: [dismiss, suppress_type]
      responses:
        '200':
          description: Nudge dismissed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean

  # =========================================================================
  # Journal — standalone journal list and delete (non-task-scoped)
  # =========================================================================
  /api/v1/journal:
    get:
      tags: [journals]
      summary: List all journal entries
      description: Paginated journal entries across all tasks
      operationId: listAllJournalEntries
      parameters:
        - in: query
          name: page
          schema:
            type: integer
            default: 1
        - in: query
          name: pageSize
          schema:
            type: integer
            default: 50
            maximum: 100
        - in: query
          name: sortBy
          schema:
            type: string
            enum: [newest, oldest]
            default: newest
      responses:
        '200':
          description: Paginated journal entries
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JournalListResponse'

  /api/v1/journal/{entryId}:
    delete:
      tags: [journals]
      summary: Delete a journal entry
      operationId: deleteJournalEntry
      parameters:
        - in: path
          name: entryId
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Entry deleted
        '404':
          description: Entry not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # =========================================================================
  # Contact — public feedback form
  # =========================================================================
  /api/v1/contact:
    post:
      tags: [contact]
      summary: Submit contact/feedback form
      description: Public endpoint — no auth required. Rate-limited.
      operationId: submitContact
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactRequest'
      responses:
        '200':
          description: Message sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded

  # =========================================================================
  # Releases — app version info
  # =========================================================================
  /api/v1/releases/latest:
    get:
      tags: [releases]
      summary: Get latest release
      description: Cached for 5 minutes.
      operationId: getLatestRelease
      security: []
      responses:
        '200':
          description: Latest release
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReleaseInfo'

  /api/v1/releases:
    get:
      tags: [releases]
      summary: List recent releases
      description: Returns last 5 releases. Cached for 5 minutes.
      operationId: listReleases
      security: []
      responses:
        '200':
          description: Recent releases
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ReleaseInfo'

  /api/v1/releases/android-version:
    get:
      tags: [releases]
      summary: Get latest published Android APK for one flavor
      description: Used by the in-app update checker (non-Play-Store installs). 404 if nothing published yet.
      operationId: getAndroidVersion
      security: []
      parameters:
        - name: flavor
          in: query
          required: true
          schema: { type: string, enum: [google, nogoogle] }
      responses:
        '200':
          description: Latest Android release for the flavor
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AndroidVersionInfo'
        '400':
          description: Missing or invalid flavor
        '404':
          description: No release published for this flavor yet
    post:
      tags: [releases]
      summary: Publish the latest Android APK for one flavor
      description: CI-only. Requires the admin Bearer token (same as routes/admin.ts).
      operationId: publishAndroidVersion
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublishAndroidVersionRequest'
      responses:
        '200':
          description: Published
          content:
            application/json:
              schema:
                type: object
                properties:
                  flavor: { type: string }
                  versionCode: { type: integer }
        '400':
          description: Invalid payload
        '401':
          description: Missing or invalid Bearer token

  # =========================================================================
  # Waitlist — early access signup
  # =========================================================================
  /api/v1/waitlist:
    post:
      tags: [waitlist]
      summary: Join waitlist
      description: Public endpoint — no auth required. Rate-limited. Upserts by email.
      operationId: joinWaitlist
      security: []
      parameters:
        - in: query
          name: utm_source
          schema:
            type: string
        - in: query
          name: utm_medium
          schema:
            type: string
        - in: query
          name: utm_campaign
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 320
                source:
                  type: string
                  maxLength: 100
                  default: landing-page
      responses:
        '200':
          description: Added to waitlist
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '429':
          description: Rate limit exceeded

  # =========================================================================
  # Email Analytics — delivery metrics
  # =========================================================================
  /api/v1/email-analytics:
    get:
      tags: [email-analytics]
      summary: Get email delivery analytics
      operationId: getEmailAnalytics
      parameters:
        - in: query
          name: days
          schema:
            type: integer
            default: 30
      responses:
        '200':
          description: Email analytics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailAnalyticsResponse'

  # =========================================================================
  # Billing — intentionally internal (x-internal: true)
  # =========================================================================
  /api/v1/billing/plans:
    get:
      tags: [admin]
      summary: Get subscription plans
      x-internal: true
      operationId: getBillingPlans
      responses:
        '200':
          description: Available plans
          content:
            application/json:
              schema:
                type: object
                required:
                  - plans
                  - billing
                properties:
                  plans:
                    type: array
                    items:
                      type: object
                      required:
                        - tier
                        - name
                        - description
                        - monthlyPrice
                        - yearlyPrice
                        - monthlyPriceId
                        - yearlyPriceId
                        - monthlyAvailable
                        - yearlyAvailable
                        - available
                        - unavailableReason
                      properties:
                        tier:
                          type: string
                        name:
                          type: string
                        description:
                          type: string
                        monthlyPrice:
                          type: integer
                        yearlyPrice:
                          type: integer
                        monthlyPriceId:
                          type: [string, 'null']
                        yearlyPriceId:
                          type: [string, 'null']
                        monthlyAvailable:
                          type: boolean
                        yearlyAvailable:
                          type: boolean
                        available:
                          type: boolean
                        unavailableReason:
                          type: [string, 'null']
                  billing:
                    type: object
                    required:
                      - monetizationEnabled
                      - checkoutEnabled
                      - unavailableReason
                    properties:
                      monetizationEnabled:
                        type: boolean
                      checkoutEnabled:
                        type: boolean
                      unavailableReason:
                        type: [string, 'null']
                      clientToken:
                        description: Publishable Paddle.js client-side token, or null when billing is disabled/unconfigured.
                        type: [string, 'null']
                      environment:
                        description: Paddle environment the client token belongs to.
                        type: string
                        enum: [sandbox, production]
  /api/v1/billing/subscription:
    get:
      tags: [admin]
      summary: Get current subscription
      x-internal: true
      operationId: getCurrentSubscription
      responses:
        '200':
          description: Current subscription
  /api/v1/billing/checkout:
    post:
      tags: [admin]
      summary: Create Paddle checkout transaction
      x-internal: true
      operationId: createBillingCheckout
      responses:
        '200':
          description: Checkout session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionId:
                    description: Alias of transactionId, retained for backward compatibility.
                    type: string
                  transactionId:
                    description: Paddle transaction id to open with Paddle.js.
                    type: string
                  url:
                    description: >-
                      Paddle payment-link URL for the transaction. Omitted unless a default
                      payment link is configured in Paddle; clients open the checkout with
                      transactionId instead.
                    type: string
                    format: uri
                  clientToken:
                    description: Publishable Paddle.js client-side token.
                    type: [string, 'null']
                  environment:
                    type: string
                    enum: [sandbox, production]
                  successUrl:
                    type: string
                    format: uri
                required:
                  - sessionId
                  - transactionId
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: >-
            Paddle rejected the request (CHECKOUT_REJECTED) or could not be reached
            (CHECKOUT_UPSTREAM_ERROR). Only the latter is worth retrying.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Billing checkout unavailable due to missing server configuration
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingNotConfiguredError'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/billing/sync:
    post:
      tags: [admin]
      summary: Reconcile the current user's subscription from Paddle
      description: >-
        Failsafe for the billing webhook path: pulls the subscription state straight from
        Paddle and updates the local record. Idempotent; safe to call repeatedly.
      x-internal: true
      operationId: syncBillingSubscription
      responses:
        '200':
          description: Reconciled subscription state
          content:
            application/json:
              schema:
                type: object
                required:
                  - matched
                  - tier
                  - status
                properties:
                  matched:
                    description: Whether Paddle has a subscription for this account.
                    type: boolean
                  tier:
                    type: string
                  status:
                    type: string
                  currentPeriodEnd:
                    type: [string, 'null']
                  cancelAtPeriodEnd:
                    type: boolean
        '502':
          description: Payment provider unreachable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Billing unavailable due to missing server configuration
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingNotConfiguredError'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/billing/transaction-ownership:
    get:
      tags: [admin]
      summary: Check whether a Paddle transaction belongs to the caller
      description: >-
        Used by the public /pay page when a session exists, so a crafted payment link
        cannot walk a signed-in visitor into paying a stranger's transaction. Returns
        only an ownership verdict, never transaction details, and answers 'unknown'
        (fail-open) whenever attribution is not possible.
      x-internal: true
      operationId: getBillingTransactionOwnership
      parameters:
        - name: transactionId
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Ownership verdict
          content:
            application/json:
              schema:
                type: object
                required:
                  - ownership
                properties:
                  ownership:
                    type: string
                    enum: [owned, foreign, unknown]
        '400':
          description: Malformed transaction id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/webhooks/paddle:
    post:
      tags: [admin]
      summary: Paddle webhook receiver
      x-internal: true
      operationId: paddleWebhook
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: Webhook processed
        '400':
          description: Invalid signature or malformed payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Processing failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/v1/billing/webhooks/stripe:
    post:
      tags: [admin]
      summary: Stripe webhook receiver (deprecated — now forwards to Paddle handler)
      description: Deprecated alias retained for backward compatibility; forwards to Paddle webhook handler.
      x-internal: true
      deprecated: true
      operationId: stripeWebhook
      security: []
      responses:
        '200':
          description: Webhook processed

  # =========================================================================
  # Productivity — Roulette, Capacity, Dependencies (Issue #861, #862, #606)
  # =========================================================================
  /api/v1/productivity/roulette/spin:
    post:
      tags: [productivity]
      summary: Spin the roulette wheel to pick a task
      operationId: spinRoulette
      responses:
        '200':
          description: Selected task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '403':
          description: Forbidden — entitlement required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No eligible tasks found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/productivity/capacity:
    get:
      tags: [productivity]
      summary: Get current daily capacity status
      operationId: getDailyCapacity
      responses:
        '200':
          description: Daily capacity status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DailyCapacity'
        '401':
          description: Unauthorized — authentication required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/productivity/capacity/settings:
    patch:
      tags: [productivity]
      summary: Update daily capacity settings
      operationId: updateDailyCapacitySettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled:
                  type: boolean
                taskLimit:
                  type: integer
                  minimum: 1
      responses:
        '200':
          description: Updated capacity settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DailyCapacity'
        '400':
          description: Validation error — invalid taskLimit or payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized — authentication required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/productivity/local-stats:
    get:
      tags: [productivity]
      summary: Get local productivity stats
      operationId: getLocalProductivityStats
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Productivity statistics computed from user's own data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LocalProductivityStats'
        '401':
          description: Unauthorized — authentication required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden — entitlement/tier required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Server error while computing stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/productivity/tasks/{id}/dependencies:
    post:
      tags: [productivity]
      summary: Add a dependency to a task
      operationId: addTaskDependency
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blockedById]
              properties:
                blockedById:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Dependency created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskDependency'
        '400':
          description: Invalid dependency (e.g. circular)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized — authentication required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/productivity/tasks/{id}/dependencies/{blockedById}:
    delete:
      tags: [productivity]
      summary: Remove a dependency from a task
      operationId: removeTaskDependency
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: blockedById
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Dependency removed
        '401':
          description: Unauthorized — authentication required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household:
    get:
      tags: [household]
      summary: Get current household
      operationId: getHousehold
      responses:
        '200':
          description: Household data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdData'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: User is not in a household
    post:
      tags: [household]
      summary: Create a household
      operationId: createHousehold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdCreateRequest'
      responses:
        '200':
          description: Household created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdData'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags: [household]
      summary: Update the household profile
      description: >-
        #2075 Settings household-admin: owner-only. #2085 extended from rename-only
        to a general profile update — name/icon/timezone are each independently
        optional so a caller can change just one field.
      operationId: renameHousehold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 120
                icon:
                  $ref: '#/components/schemas/HouseholdIconKey'
                timezone:
                  type: string
                  description: IANA timezone id. Display-only in this phase.
                notifyOverrideQuietHours:
                  type: boolean
                  description: >-
                    Household-wide override that bypasses each recipient's
                    quiet-hours window for chore reminder pushes.
                assignmentPosture:
                  $ref: '#/components/schemas/AssignmentPosture'
                childrenSeeErrandPool:
                  type: boolean
                  description: >-
                    Owner opt-in: unassigned/unclaimed errands become visible
                    (view-only) to child members. Off by default.
                errandGeofenceEnabled:
                  type: boolean
                  description: >-
                    #2223 item2: owner opt-in gating the errand-geofence UI.
                    Off by default.
                baseVersion:
                  type: integer
                  minimum: 0
                  description: >-
                    #2166 optimistic-concurrency guard: when sent, the write 409s
                    (SETTINGS_VERSION_CONFLICT) unless it matches the server's
                    current settingsVersion. Omitted = last-write-wins.
      responses:
        '200':
          description: Household profile updated
          content:
            application/json:
              schema:
                type: object
                required: [household]
                properties:
                  household:
                    type: object
                    required: [id, name, icon, timezone, notifyOverrideQuietHours, settingsVersion]
                    properties:
                      id:
                        type: string
                      name:
                        type: string
                      icon:
                        $ref: '#/components/schemas/HouseholdIconKey'
                      timezone:
                        type: string
                      notifyOverrideQuietHours:
                        type: boolean
                      assignmentPosture:
                        $ref: '#/components/schemas/AssignmentPosture'
                      childrenSeeErrandPool:
                        type: boolean
                      errandGeofenceEnabled:
                        type: boolean
                      settingsVersion:
                        type: integer
                        minimum: 0
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Not a household owner
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: baseVersion did not match the current settingsVersion — no write happened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdSettingsVersionConflict'

  /api/v1/household/memberships:
    get:
      tags: [household]
      summary: List households the signed-in user belongs to
      description: >-
        Returns every non-archived household the authenticated (non-child) user is a member of,
        for the client household switcher. Archived households (#2011) are excluded by default —
        switchers only need active ones. Pass includeArchived=true to also return archived
        memberships (#2021, the "Archived households" restore surface). Send the chosen
        household's id back as the X-Household-Id request header on subsequent household
        requests to scope them to that household.
      operationId: listHouseholdMemberships
      parameters:
        - name: includeArchived
          in: query
          required: false
          description: >-
            #2021: when true, also returns memberships for archived households (identifiable
            via a non-null archivedAt). Default (omitted or any other value) excludes them,
            matching the pre-#2021 behavior.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: Household memberships
          content:
            application/json:
              schema:
                type: object
                required: [households]
                properties:
                  households:
                    type: array
                    items:
                      type: object
                      required: [memberId, householdId, name, role, accountType, memberCount, archivedAt]
                      properties:
                        memberId:
                          type: string
                        householdId:
                          type: string
                        name:
                          type: string
                        role:
                          type: string
                        accountType:
                          type: string
                        memberCount:
                          type: integer
                          description: >-
                            #2019: total members in the household. Lets client switchers
                            label a solo household (memberCount = 1) as the user's rewards
                            tracker instead of comparing against the backend default name.
                        archivedAt:
                          oneOf:
                            - type: string
                              format: date-time
                            - type: 'null'
                          description: >-
                            #2021: null for an active household; an ISO timestamp for an
                            archived one (only ever non-null when includeArchived=true).
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Not available for child accounts or plan does not include household
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks:
    post:
      tags: [household]
      summary: Create a household task
      operationId: createHouseholdTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdTaskCreateRequest'
      responses:
        '200':
          description: Task created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdTask'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/assign:
    patch:
      tags: [household]
      summary: Assign a household task
      operationId: assignHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdAssignRequest'
      responses:
        '200':
          description: Task assigned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdChoreAssignment'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/unassign:
    post:
      tags: [household]
      summary: Unassign a household task
      operationId: unassignHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Task unassigned
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/claim:
    post:
      tags: [household]
      summary: Claim a household task
      operationId: claimHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task claimed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdChoreAssignment'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/complete:
    post:
      tags: [household]
      summary: Complete a household task
      operationId: completeHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task completed or pending approval
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdTask'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/approve:
    post:
      tags: [household]
      summary: Approve a completed household task
      operationId: approveHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task approved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdTask'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/decline:
    post:
      tags: [household]
      summary: Decline a pending-approval household task, sending it back to active
      operationId: declineHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task declined
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdTask'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Task is not currently pending approval
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/archive:
    post:
      tags: [household]
      summary: Archive a household task
      operationId: archiveHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task archived
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdTask'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}:
    delete:
      tags: [household]
      summary: Delete a household task
      description: >-
        #2235: soft-deletes by default (30-day restore window via
        POST /{id}/restore, chore assignment untouched). ?permanent=true skips
        the restore window and hard-deletes immediately (with its chore
        assignment) — purge worker/GDPR export tooling only.
      operationId: deleteHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: permanent
          in: query
          required: false
          schema:
            type: boolean
      responses:
        '204':
          description: Task deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/restore:
    post:
      tags: [household]
      summary: Restore a soft-deleted household task
      description: >-
        #2235: undo a soft delete within the 30-day retention window.
        Permission check mirrors the delete's. 404s once purged.
      operationId: restoreHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Task restored
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found (never deleted, or already purged)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/settings:
    patch:
      tags: [household]
      summary: Enable or disable the household chore economy (owner only)
      operationId: updateHouseholdEconomySettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdEconomySettingsRequest'
      responses:
        '200':
          description: Economy settings updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdEconomySettingsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: baseVersion did not match the current settingsVersion — no write happened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdSettingsVersionConflict'

  /api/v1/household/economy/balance:
    get:
      tags: [household]
      summary: Per-member point balances (child sessions see only their own)
      operationId: getHouseholdEconomyBalances
      responses:
        '200':
          description: Balances
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdEconomyBalancesResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/ledger:
    get:
      tags: [household]
      summary: Point ledger for a member. Child sessions are locked to their own ledger; adult members may view any household member's ledger (household transparency, same visibility as balances).
      operationId: getHouseholdEconomyLedger
      parameters:
        - name: memberId
          in: query
          required: false
          schema:
            type: string
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Ledger entries
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdEconomyLedgerResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/rewards:
    get:
      tags: [household]
      summary: List household rewards (owners also see deactivated rewards)
      operationId: listHouseholdEconomyRewards
      responses:
        '200':
          description: Rewards
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdRewardsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags: [household]
      summary: Create a reward (owner only)
      operationId: createHouseholdEconomyReward
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdRewardCreateRequest'
      responses:
        '201':
          description: Reward created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdReward'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/starter-packs:
    get:
      tags: [household]
      summary: List starter packs for bootstrapping household chore economy
      operationId: listHouseholdEconomyStarterPacks
      responses:
        '200':
          description: Starter packs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdEconomyStarterPacksResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/starter-packs/{packId}/apply:
    post:
      tags: [household]
      summary: Apply one starter pack to create initial chores and rewards
      operationId: applyHouseholdEconomyStarterPack
      parameters:
        - name: packId
          in: path
          required: true
          schema:
            type: string
            enum: [solo-reward-board, kids-chore-chart, roommate-split]
      responses:
        '201':
          description: Starter pack applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdEconomyStarterPackApplyResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden or reward cap reached
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Starter pack or child member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Starter pack was already applied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/rewards/{id}:
    patch:
      tags: [household]
      summary: Update or deactivate a reward (owner only)
      operationId: updateHouseholdEconomyReward
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdRewardUpdateRequest'
      responses:
        '200':
          description: Reward updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdReward'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Reward not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [household]
      summary: Delete a reward (owner only) — soft-delete, cannot be restored
      operationId: deleteHouseholdEconomyReward
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Reward deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Reward not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/redemptions:
    get:
      tags: [household]
      summary: List redemptions (owners see the household queue; members see their own)
      operationId: listHouseholdEconomyRedemptions
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [pending, approved, denied]
      responses:
        '200':
          description: Redemptions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdRedemptionsResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/redeem:
    post:
      tags: [household]
      summary: Request a reward redemption (points are not deducted until approval)
      operationId: redeemHouseholdEconomyReward
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdEconomyRedeemRequest'
      responses:
        '201':
          description: Redemption requested
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdRedemption'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Insufficient points
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/redemptions/{id}/approve:
    post:
      tags: [household]
      summary: Approve a redemption and deduct points (owner only)
      operationId: approveHouseholdEconomyRedemption
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Redemption approved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdRedemption'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Already resolved or insufficient points
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/redemptions/{id}/deny:
    post:
      tags: [household]
      summary: Deny a redemption without deducting points (owner only)
      operationId: denyHouseholdEconomyRedemption
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Redemption denied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdRedemption'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Already resolved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/adjust:
    post:
      tags: [household]
      summary: Manually adjust a member's balance (owner only)
      operationId: adjustHouseholdEconomyBalance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdEconomyAdjustRequest'
      responses:
        '201':
          description: Ledger entry created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdPointLedgerEntry'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/fairness:
    get:
      tags: [household]
      summary: Mental-load fairness report (opt-in per household)
      description: |
        #1670 Returns per-member doing/noticing/delegating/minutes plus a code-computed
        calm-framing insight. Off by default: when the household hasn't opted in, returns
        200 with { enabled: false }. Child sessions see only their own row.
      operationId: getHouseholdFairnessReport
      parameters:
        - name: window
          in: query
          required: false
          schema:
            type: string
            enum: ['7d', '30d']
            default: '7d'
      responses:
        '200':
          description: Fairness report (or disabled marker)
          content:
            application/json:
              schema:
                type: object
                required: [enabled, window]
                properties:
                  enabled:
                    type: boolean
                  window:
                    type: string
                    enum: ['7d', '30d']
                  windowStart:
                    type: string
                    format: date-time
                  insight:
                    type: string
                  members:
                    type: array
                    items:
                      type: object
                      required: [memberId, displayName, doing, noticing, delegating, minutes, sharePercent]
                      properties:
                        memberId:
                          type: string
                        displayName:
                          type: string
                        doing:
                          type: integer
                        noticing:
                          type: integer
                        delegating:
                          type: integer
                        minutes:
                          type: integer
                        sharePercent:
                          type: integer
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/setup-from-suggestions:
    post:
      tags: [household]
      summary: Bulk-create chores and rewards from suggestions (owner only)
      description: '#1723 AI Smart Ledger — create chore tasks + rewards from posted suggestions.'
      operationId: setupHouseholdEconomyFromSuggestions
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                chores:
                  type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [title]
                    properties:
                      title:
                        type: string
                      description:
                        type: string
                      effortWeight:
                        type: integer
                        minimum: 1
                        maximum: 3
                      taskTier:
                        type: string
                        enum: [baseline, hustle]
                rewards:
                  type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [title, pointCost]
                    properties:
                      title:
                        type: string
                      pointCost:
                        type: integer
                        minimum: 1
                        maximum: 999
      responses:
        '201':
          description: Chores and rewards created
          content:
            application/json:
              schema:
                type: object
                required: [createdTaskIds, rewards]
                properties:
                  createdTaskIds:
                    type: array
                    items:
                      type: string
                  rewards:
                    type: array
                    items:
                      $ref: '#/components/schemas/HouseholdReward'
        '403':
          description: Forbidden or reward cap reached
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/economy/adjust/smart:
    post:
      tags: [household]
      summary: Natural-language point adjustment, deterministically parsed (owner only)
      description: |
        #1723 Parses free text like "Give Sam 10 points for the dishes" — fuzzy member
        match + integer delta + reason. Returns 422 on no/ambiguous member or no amount.
      operationId: adjustHouseholdEconomyBalanceSmart
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  minLength: 1
                  maxLength: 500
      responses:
        '201':
          description: Ledger entry created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/HouseholdPointLedgerEntry'
                  - type: object
                    properties:
                      clamped:
                        type: boolean
                        description: True when the parsed amount was capped to the ±10000 bound.
        '422':
          description: Could not confidently parse the adjustment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/routines/templates/{templateId}/rotate:
    post:
      tags: [household]
      summary: Manually rotate a household chore routine template (owner only)
      description: |
        #1916 FULL rotation parity — effort-weighted fairness, vacation/holiday skip,
        audit trail, and FCM notifications. Adults only. Returns status 'rotated' or a
        benign 'skipped' (holiday / no eligible members).
      operationId: rotateHouseholdRoutineTemplate
      parameters:
        - name: templateId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Rotation ran or was skipped
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [rotated, skipped]
                  reason:
                    type: string
                  templateId:
                    type: string
                  fromAssigneeId:
                    type:
                      - string
                      - 'null'
                  toAssigneeId:
                    type:
                      - string
                      - 'null'
                  rotationCycle:
                    type: integer
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Routine template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/reopen:
    post:
      tags: [household]
      summary: Reopen a completed household task
      operationId: reopenHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task reopened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdTask'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/adopt:
    post:
      tags: [household]
      summary: Move one of the caller's personal tasks into their household
      operationId: adoptHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum: [shared, errand]
                  default: shared
      responses:
        '200':
          description: Task moved into the household
          content:
            application/json:
              schema:
                type: object
                required: [task]
                properties:
                  task:
                    $ref: '#/components/schemas/HouseholdTask'
        '403':
          description: Not an adult household member
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found or not the caller's personal task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/tasks/{id}/detach:
    post:
      tags: [household]
      summary: Convert a household task back into a personal task owned by the caller
      operationId: detachHouseholdTask
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Task detached from the household
          content:
            application/json:
              schema:
                type: object
                required: [task]
                properties:
                  task:
                    $ref: '#/components/schemas/HouseholdTask'
        '403':
          description: Not permitted (child session, or member detaching another's task)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Task not found in the caller's household
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/routines:
    get:
      tags: [household]
      summary: List household routine templates (chores)
      operationId: listHouseholdRoutines
      responses:
        '200':
          description: Household routine templates
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/RoutineTemplateV2'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Child sessions cannot access routine details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/routines/stats:
    get:
      tags: [household]
      summary: Get aggregate household routine (chore) metrics, including per-template 26-week heatmap data
      operationId: getHouseholdRoutineStats
      parameters:
        - name: startDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
        - name: endDate
          in: query
          required: false
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Household routine metrics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineV2StatsResponse'
        '400':
          description: Invalid startDate/endDate (unparseable, inverted, or exceeding the maximum range)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Child sessions cannot access routine details, or the household lacks the advancedStats entitlement
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/routines/{id}/run-rotation:
    post:
      tags: [household]
      summary: Run rotation for a routine
      operationId: runHouseholdRotation
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdRotationRunRequest'
      responses:
        '200':
          description: Rotation ran
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdRotationResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/routines/calendar:
    get:
      tags: [household]
      summary: Get household routine calendar rollups, optionally filtered by assignee (#1650)
      description: >-
        Same per-day rollup as GET /api/v2/routines/calendar, scoped to household-tagged
        routines instead of the caller's personal ones. Any adult household member (owner
        or member role) can view all household-tagged routines/occurrences — this matches
        the household's existing visibility model (no new privacy rules). Child PIN
        sessions are forbidden, matching every other routine-detail endpoint under
        /household/routines. assigneeMemberId narrows the result to one member's assigned
        routines and must belong to the caller's own household. Note visibility: each
        occurrence's notes field, when present, is visible to any adult household member
        who can see the occurrence, identically to how notes is already visible on
        /routines/today and /routines/:id — household-tagged routine notes are not private
        per-member data, and this endpoint introduces no new note-privacy behavior.
      operationId: getHouseholdRoutineCalendarV2
      parameters:
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date
            example: '2026-06-01'
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date
            example: '2026-06-30'
        - name: assigneeMemberId
          in: query
          required: false
          description: Household member id to filter to; must belong to the caller's household.
          schema:
            type: string
      responses:
        '200':
          description: Per-day household routine completion rollups
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoutineCalendarResponse'
        '400':
          description: Validation error (bad date range, or assigneeMemberId not in the caller's household)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Forbidden (not a household member, or a child PIN session)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/invite:
    post:
      tags: [household]
      summary: Invite someone to the household
      operationId: inviteToHousehold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdInviteCreateRequest'
      responses:
        '201':
          description: Invite created
          content:
            application/json:
              schema:
                type: object
                required: [invite, status]
                properties:
                  invite:
                    $ref: '#/components/schemas/HouseholdInvite'
                  status:
                    type: string
                  email:
                    type:
                      - string
                      - 'null'
                  inviteLink:
                    type: string
                    description: >-
                      Shareable invite URL, present only for a link invite
                      (no email) and only in this creation response — never
                      returned again by the list/history endpoints.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/invites:
    get:
      tags: [household]
      summary: Get pending household invites
      operationId: getHouseholdInvites
      responses:
        '200':
          description: Invites list
          content:
            application/json:
              schema:
                type: object
                required: [invites]
                properties:
                  invites:
                    type: array
                    items:
                      $ref: '#/components/schemas/HouseholdInvite'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/invites/history:
    get:
      tags: [household]
      summary: Get terminal-status household invites (accepted/declined/expired/revoked)
      description: >-
        Owner-only Invitations-tab History section. Distinct from GET
        /household/invites, which returns the signed-in user's own pending
        invites across households, not this household's roster history.
      operationId: getHouseholdInviteHistory
      responses:
        '200':
          description: Invite history
          content:
            application/json:
              schema:
                type: object
                required: [invites]
                properties:
                  invites:
                    type: array
                    items:
                      $ref: '#/components/schemas/HouseholdInvite'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can view invitation history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/invites/preview:
    get:
      tags: [household]
      summary: Preview a household invite (unauthenticated)
      description: >-
        Public landing data for an emailed invite link — the recipient may be
        signed out, so this endpoint is mounted before auth (same pattern as
        pin-login). The invite token is the bearer secret; the response exposes
        only household name, inviter display name, role, and expiry. Rate-limited
        (read limiter). Terminal invite states return 410 Gone with a typed
        invite_* code; unknown or archived-household tokens return 404
        invite_not_found.
      operationId: previewHouseholdInvite
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 128
      responses:
        '200':
          description: Invite preview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdInvitePreview'
        '400':
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitePreviewError'
        '404':
          description: Unknown token (also masks invites into archived households)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitePreviewError'
        '410':
          description: Invite expired, already used, or revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitePreviewError'
        '429':
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitePreviewError'
        '503':
          description: Household feature disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitePreviewError'

  /api/v1/household/invites/accept:
    post:
      tags: [household]
      summary: Accept a household invite
      operationId: acceptHouseholdInvite
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdInviteAcceptRequest'
      responses:
        '200':
          description: Invite accepted
          content:
            application/json:
              schema:
                type: object
                required: [status, invite, householdId, membershipRole, soloHousehold]
                properties:
                  status:
                    type: string
                  invite:
                    $ref: '#/components/schemas/HouseholdInvite'
                  householdId:
                    type: string
                  membershipRole:
                    type: string
                  soloHousehold:
                    type: [object, 'null']
                    required: [id, name]
                    description: >-
                      #2011: the acceptor's other household when it is their auto-created
                      solo household (single member, not archived) — clients prompt
                      "keep it as a rewards tracker, or archive it?". Null when the user
                      has no such household. The acceptor's active household is always
                      repointed to the household just joined.
                    properties:
                      id:
                        type: string
                      name:
                        type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/invites/{inviteId}:
    delete:
      tags: [household]
      summary: Revoke a household invite
      operationId: revokeHouseholdInvite
      parameters:
        - name: inviteId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Invite revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdInvite'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/invites/{inviteId}/decline:
    post:
      tags: [household]
      summary: Decline a household invite (invitee action)
      operationId: declineHouseholdInvite
      parameters:
        - name: inviteId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Invite declined
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                  inviteId:
                    type: string
                required: [status, inviteId]
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Invite is for a different user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Invite not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Failed to decline household invite
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/members/{userId}:
    delete:
      tags: [household]
      summary: Remove a household member
      operationId: removeHouseholdMember
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Member removed
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/members/{userId}/role:
    patch:
      tags: [household]
      summary: Change a member's role (promote to co-owner / demote to member)
      description: >-
        Owner-tier callers (owner or co-owner) promote an adult member to co-owner or demote a
        co-owner back to member. The original owner's row is immutable here — ownership moves
        only through transfer-ownership. Child accounts and self-changes are rejected.
      operationId: changeHouseholdMemberRole
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [co-owner, member]
      responses:
        '200':
          description: Updated member list
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: '#/components/schemas/HouseholdMember'
        '400':
          description: Invalid target (self, child account, or bad role)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Caller is not owner-tier, or target is the owner
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/leave:
    post:
      tags: [household]
      summary: Leave current household
      operationId: leaveHousehold
      responses:
        '204':
          description: Left household
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/{id}/archive:
    post:
      tags: [household]
      summary: Archive a household (owner only)
      description: >-
        #2011: hides the household from memberships/switchers and never resolves it as the
        active household; member reads then behave as not-found/forbidden. If the caller's
        active household pointer was this household, it is repointed to another non-archived
        membership. Reversible via unarchive — there is no delete-household API. Idempotent.
      operationId: archiveHousehold
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Household archived
          content:
            application/json:
              schema:
                type: object
                required: [status, householdId]
                properties:
                  status:
                    type: string
                  householdId:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Caller is not an owner of this household
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Household not found or caller is not a member
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/{id}/unarchive:
    post:
      tags: [household]
      summary: Restore an archived household (owner only)
      description: >-
        #2011: clears the archived flag so the household appears in memberships/switchers
        again. Does not move the caller's active household pointer — clients select the
        restored household explicitly. Idempotent.
      operationId: unarchiveHousehold
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Household restored
          content:
            application/json:
              schema:
                type: object
                required: [status, householdId]
                properties:
                  status:
                    type: string
                  householdId:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Caller is not an owner of this household
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Household not found or caller is not a member
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/children:
    post:
      tags: [household]
      summary: Create a child account
      operationId: createHouseholdChild
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdCreateChildRequest'
      responses:
        '200':
          description: Child created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdMember'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can manage children
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/children/{memberId}:
    patch:
      tags: [household]
      summary: Update a child account
      operationId: updateHouseholdChild
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdUpdateChildRequest'
      responses:
        '200':
          description: Child updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdMember'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can manage children
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags: [household]
      summary: Remove a child account
      operationId: removeHouseholdChild
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Child removed
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can manage children
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/members/{memberId}/capabilities:
    patch:
      tags: [household]
      summary: Set granular capabilities for a child member
      operationId: setMemberCapabilities
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MemberCapabilities'
      responses:
        '200':
          description: Capabilities updated
          content:
            application/json:
              schema:
                type: object
                required: [member, capabilities]
                properties:
                  member:
                    $ref: '#/components/schemas/HouseholdMember'
                  capabilities:
                    $ref: '#/components/schemas/MemberCapabilities'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can set capabilities
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/members/{memberId}/vacation:
    patch:
      tags: [household]
      summary: Set vacation/away ranges for a household member
      operationId: setMemberVacationRanges
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MemberVacationRanges'
      responses:
        '200':
          description: Vacation ranges updated
          content:
            application/json:
              schema:
                type: object
                required: [member]
                properties:
                  member:
                    $ref: '#/components/schemas/HouseholdMember'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can set vacation ranges
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/members/{memberId}/eligibility:
    patch:
      tags: [household]
      summary: Set chore/rewards eligibility for a managed household profile
      operationId: setMemberEligibility
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MemberEligibility'
      responses:
        '200':
          description: Eligibility updated
          content:
            application/json:
              schema:
                type: object
                required: [member]
                properties:
                  member:
                    $ref: '#/components/schemas/HouseholdMember'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can set eligibility
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Member not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/geocode:
    get:
      tags: [geocode]
      summary: Server-side address geocoding proxy (config-14)
      description: >-
        Proxies a forward-geocoding lookup so the browser never calls the
        third-party provider directly (CSP-safe, cached, rate-limited).
      operationId: searchGeocode
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            maxLength: 200
      responses:
        '200':
          description: Geocoding results (possibly empty)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeocodeSearchResponse'
        '400':
          description: Invalid query
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: Upstream geocoding lookup failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/household/rotation-config:
    get:
      tags: [household]
      summary: Get chore rotation fairness config
      operationId: getRotationConfig
      responses:
        '200':
          description: Rotation config
          content:
            application/json:
              schema:
                type: object
                required: [rotationConfig, settingsVersion]
                properties:
                  rotationConfig:
                    $ref: '#/components/schemas/RotationConfig'
                  settingsVersion:
                    type: integer
                    minimum: 0
                    description: '#2166 household settings version — send as baseVersion on the next settings write.'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags: [household]
      summary: Update chore rotation fairness config
      operationId: setRotationConfig
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RotationConfigPatch'
      responses:
        '200':
          description: Rotation config updated
          content:
            application/json:
              schema:
                type: object
                required: [rotationConfig, settingsVersion]
                properties:
                  rotationConfig:
                    $ref: '#/components/schemas/RotationConfig'
                  settingsVersion:
                    type: integer
                    minimum: 0
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Only owners can change rotation config
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: baseVersion did not match the current settingsVersion — no write happened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdSettingsVersionConflict'

  /api/v1/household/sync-protocol:
    get:
      tags: [household]
      summary: Get the household sync protocol (#1668 Step 9)
      description: |
        Returns the household's sync protocol. `changelog` (default) routes
        household task updates, deletes and complete/reopen verbs through
        `/api/v1/sync/changes`; `merge` keeps writes on the REST routes as an
        explicit opt-out.
      operationId: getHouseholdSyncProtocol
      responses:
        '200':
          description: Current sync protocol
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdSyncProtocolState'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Caller is not part of a household
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      tags: [household]
      summary: Switch the household sync protocol (owner-only)
      description: |
        Whole-household cutover — a household must not straddle two protocols.
        Flipping to `changelog` requires the server change-log feature flag and
        the `syncMode: auto` entitlement; flipping back to `merge` is always
        allowed so a household can never be stranded on an unavailable protocol.
      operationId: setHouseholdSyncProtocol
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [syncProtocol]
              properties:
                syncProtocol:
                  $ref: '#/components/schemas/SyncProtocol'
                baseVersion:
                  type: integer
                  minimum: 0
                  description: >-
                    #2166 optimistic-concurrency guard: when sent, the write 409s
                    (SETTINGS_VERSION_CONFLICT) unless it matches the server's
                    current settingsVersion. Omitted = last-write-wins.
      responses:
        '200':
          description: Sync protocol updated — full state, same shape as the GET.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdSyncProtocolState'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: |
            Only owners can change the sync protocol, the change-log feature is
            unavailable on this server, or the `syncMode: auto` entitlement is
            required to switch to `changelog`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: baseVersion did not match the current settingsVersion — no write happened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdSettingsVersionConflict'

  /api/v1/household/pin-login:
    post:
      tags: [household]
      summary: Authenticate a child account with PIN
      operationId: pinLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HouseholdPinLoginRequest'
      responses:
        '200':
          description: PIN verified, JWT issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HouseholdPinLoginResponse'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Invalid PIN
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Too many failed PIN attempts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
