openapi: 3.1.0
info:
  title: Quietdesk API
  version: 0.9.0-beta
  description: |
    The Quietdesk management API — the **same vocabulary** the UI, the `qd` CLI,
    and the MCP server use. JSON over HTTPS, bearer tokens, cursor pagination,
    idempotency keys on writes.

    **Status: draft (open beta).** This spec is hand-maintained during the beta and
    will be replaced by the spec generated from the canonical Zod schemas
    (`@portal/schemas`) before general availability. Shapes may gain fields; they
    will not change meaning.
servers:
  - url: https://api.quietdesk.dev/v1
tags:
  - name: Snippets
    description: Versioned fragments with revocable public links.
  - name: Cards
    description: Board cards — the unit of team work in motion.
  - name: Todos
    description: Personal commitments ("Today"). Card-linked todos stay linked.
  - name: Milestones
    description: Sprints and deadlines with honest criteria counting.
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: "qd_live_…"
  parameters:
    cursor:
      name: cursor
      in: query
      description: Opaque pagination cursor from a previous response.
      schema: { type: string }
    limit:
      name: limit
      in: query
      description: "Page size (default 25, max 100)."
      schema: { type: integer, default: 25, maximum: 100 }
  schemas:
    Error:
      type: object
      description: "Every error uses this envelope. `internal_detail` is never exposed."
      properties:
        code: { type: string, examples: [snippet_not_found] }
        http_status: { type: integer, examples: [404] }
        user_message: { type: string, examples: ["This snippet does not exist or was deleted."] }
        request_id: { type: string, examples: [req_01JX9A2K] }
      required: [code, http_status, user_message, request_id]
    Snippet:
      type: object
      properties:
        id: { type: string, examples: [snp_7c4b2e09] }
        org_id: { type: string, examples: [org_8f3a] }
        type: { type: string, enum: [html, sql, json, txt] }
        name: { type: string, examples: ["Refund eligibility check — last 90 days"] }
        slug: { type: string, examples: [refund-eligibility-90d] }
        category: { type: string, examples: ["SQL queries / Reporting"] }
        tags: { type: array, items: { type: string }, examples: [["stripe", "refunds"]] }
        visibility: { type: string, enum: [private, team] }
        version: { type: integer, examples: [7] }
        public_url:
          type: string
          description: "Revocable public link. Returns 404 after deletion or revocation."
          examples: ["https://qd.sk/s/refund-eligibility-90d"]
        body: { type: string }
        updated_at: { type: string, format: date-time }
      required: [id, org_id, type, name, slug, visibility, version, body]
    Card:
      type: object
      properties:
        id: { type: string, examples: [crd_PS117] }
        ref: { type: string, description: "Human reference used in branch names.", examples: [PS-117] }
        board_id: { type: string, examples: [brd_q3roadmap] }
        title: { type: string, examples: ["Stripe refund flow — partial refunds for annual plans"] }
        workflow_status: { type: string, enum: [backlog, in_progress, in_review, done] }
        gated:
          type: boolean
          description: "True when the next move requires a human caller (agents are rejected)."
        criteria_done: { type: integer, examples: [2] }
        criteria_total: { type: integer, examples: [2] }
        due_on: { type: string, format: date, examples: ["2026-07-18"] }
        assignee_ids: { type: array, items: { type: string } }
      required: [id, ref, board_id, title, workflow_status]
    Todo:
      type: object
      properties:
        id: { type: string, examples: [tdo_01JX4R8K] }
        title: { type: string, examples: ["Reply to the Stripe support thread re: refund proration"] }
        priority: { type: string, enum: [urgent, high, medium, low] }
        due_on: { type: string, format: date }
        org_id: { type: string, description: "Absent for personal todos." }
        card_id: { type: string, description: "Present when the todo mirrors a card subtask." }
        done: { type: boolean }
      required: [id, title, priority, done]
    Milestone:
      type: object
      properties:
        id: { type: string, examples: [ms_SP14] }
        ref: { type: string, examples: [SP-14] }
        name: { type: string, examples: ["Sprint 14 — ship the refund flow"] }
        kind: { type: string, enum: [sprint, deadline] }
        cards_done: { type: integer, examples: [14] }
        cards_total: { type: integer, examples: [21] }
        criteria_done: { type: integer, examples: [41] }
        criteria_total: { type: integer, examples: [52] }
        ends_on: { type: string, format: date, examples: ["2026-07-18"] }
      required: [id, ref, name, kind, cards_done, cards_total]
security:
  - bearer: []
paths:
  /snippets:
    get:
      operationId: listSnippets
      tags: [Snippets]
      summary: List snippets
      description: "Org-scoped. Filter by type, tag, or full-text query; sort by `updated_at`."
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - { name: type, in: query, schema: { type: string, enum: [html, sql, json, txt] } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: q, in: query, description: Full-text query., schema: { type: string } }
      responses:
        '200':
          description: A page of snippets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Snippet' } }
                  next_cursor: { type: string }
    post:
      operationId: createSnippet
      tags: [Snippets]
      summary: Create a snippet
      description: |
        Creates v.01 and returns the snippet **including its `public_url`** — the same
        "save & copy link" gesture the UI performs. Send `Idempotency-Key` to make
        retries safe.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                type: { type: string, enum: [html, sql, json, txt] }
                name: { type: string }
                body: { type: string }
                category: { type: string }
                tags: { type: array, items: { type: string } }
                visibility: { type: string, enum: [private, team], default: team }
              required: [type, name, body]
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Snippet' }
        '422':
          description: Validation failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /snippets/{id}:
    get:
      operationId: getSnippet
      tags: [Snippets]
      summary: Get a snippet
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The snippet, current version.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Snippet' }
        '404':
          description: Not found (or deleted — public links 404 too).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      operationId: deleteSnippet
      tags: [Snippets]
      summary: Delete a snippet
      description: "Removes all versions and revokes the public link (it returns 404 from then on)."
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '204': { description: Deleted. }
  /snippets/{id}/link:
    post:
      operationId: rotateSnippetLink
      tags: [Snippets]
      summary: Rotate the public link
      description: "Revokes the current `public_url` and issues a new one. The old link 404s immediately."
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: New link issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  public_url: { type: string, examples: ["https://qd.sk/s/refund-eligibility-90d-2"] }
  /cards:
    get:
      operationId: listCards
      tags: [Cards]
      summary: List cards
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - { name: board, in: query, schema: { type: string }, description: Board id or slug. }
        - { name: status, in: query, schema: { type: string, enum: [backlog, in_progress, in_review, done] } }
        - { name: assignee, in: query, schema: { type: string }, description: "`me` or a member id." }
      responses:
        '200':
          description: A page of cards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Card' } }
                  next_cursor: { type: string }
  /cards/{id}:
    patch:
      operationId: moveCard
      tags: [Cards]
      summary: Update / move a card
      description: |
        The verb behind `qd cards move` and MCP `cards.move`. **Gated moves**
        (into `done` on gated boards) require a human caller — agent tokens
        receive `403 gated_move_requires_human`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                workflow_status: { type: string, enum: [backlog, in_progress, in_review, done] }
                title: { type: string }
                due_on: { type: string, format: date }
      responses:
        '200':
          description: Updated card.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Card' }
        '403':
          description: "Gated move attempted by an agent caller."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /todos:
    get:
      operationId: listTodos
      tags: [Todos]
      summary: List my todos
      description: "Always the caller's own todos. Filter by scope (`org`, `personal`), due window, or done state."
      parameters:
        - $ref: '#/components/parameters/cursor'
        - { name: due, in: query, schema: { type: string, enum: [overdue, today, week] } }
        - { name: done, in: query, schema: { type: boolean } }
      responses:
        '200':
          description: A page of todos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Todo' } }
                  next_cursor: { type: string }
    post:
      operationId: createTodo
      tags: [Todos]
      summary: Create a todo
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                priority: { type: string, enum: [urgent, high, medium, low], default: medium }
                due_on: { type: string, format: date }
                org_id: { type: string, description: "Omit for a personal todo." }
              required: [title]
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Todo' }
  /todos/{id}/done:
    post:
      operationId: completeTodo
      tags: [Todos]
      summary: Complete a todo
      description: "Card-linked todos also tick the mirrored subtask on the card."
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The completed todo.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Todo' }
  /milestones/{ref}/status:
    get:
      operationId: getMilestoneStatus
      tags: [Milestones]
      summary: Milestone status
      description: |
        The same answer every door gives — `qd milestones status sp-14`, MCP
        `milestones.status`, Slack `/qd milestones status sp-14`. Criteria are
        counted separately from card status.
      parameters:
        - { name: ref, in: path, required: true, schema: { type: string }, example: SP-14 }
      responses:
        '200':
          description: Milestone with honest counts.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Milestone' }
