{
  "openapi": "3.1.0",
  "info": {
    "title": "Lithora API",
    "version": "2.0.0",
    "description": "Curated, hand-maintained OpenAPI description of the **public surface** of the Lithora Work Management REST API.\n\nThis document is a stable, reviewed subset intended for SDK generation and external integrators. It is NOT the exhaustive machine-generated schema. FastAPI's own interactive docs (`/api/docs`, `/api/redoc`) and runtime schema (`/api/openapi.json`) are **disabled in production** by design (backend/server.py), so this file is the machine-readable contract for anyone outside the codebase. The exhaustive machine-generated counterpart is committed alongside it at `docs/api/openapi.generated.json` and is regenerated with `make openapi`; `make openapi-curated` fails if this file still documents an operation that document no longer contains.\n\nAuth: send `Authorization: Bearer <credential>`. The credential is either a **personal access token** (recommended for scripts, CI, the CLI and the SDKs — format `lth_pat_<random>`, minted at Settings -> API Tokens, scoped and expiring) or the **JWT** returned by `POST /api/auth/login`. Login may return an authentication *challenge* (`requires_2fa` or `requires_login_otp`) instead of a token; clients must handle that. There is no direct registration endpoint — accounts are created through the email-OTP signup flow in the web app.\n\nAll paths are prefixed with `/api`. All requests and responses are JSON. Errors use the structured envelope described by the `Error` schema and carry an `X-Correlation-ID` response header.",
    "contact": {
      "name": "Lithora API",
      "url": "https://api.lithora.app"
    }
  },
  "servers": [
    {
      "url": "https://api.lithora.app",
      "description": "Production"
    },
    {
      "url": "http://localhost:8000",
      "description": "Local development"
    }
  ],
  "tags": [
    {
      "name": "Auth",
      "description": "Login and the current-user endpoint. There is no direct registration route — accounts are created through the email-OTP signup flow in the web app."
    },
    {
      "name": "Auth Tokens",
      "description": "Personal access tokens — scoped, expiring API keys for the CLI, SDKs and CI. These routes reject PAT-authenticated callers: a token can never manage tokens."
    },
    {
      "name": "Teams",
      "description": "Teams and their membership."
    },
    {
      "name": "Projects",
      "description": "Projects scoped to a team."
    },
    {
      "name": "Tasks",
      "description": "Tasks within a project, including GitHub linkage."
    },
    {
      "name": "Work Items",
      "description": "Unified work graph spanning task/project/file/note/whiteboard/doc, with typed relations."
    },
    {
      "name": "AI Workspace",
      "description": "Confirmation-gated agent that reads and operates the work graph."
    },
    {
      "name": "GitHub",
      "description": "Inbound GitHub webhook ingestion (HMAC-verified, fail-closed)."
    },
    {
      "name": "Automations",
      "description": "Trigger -> condition -> action rules. Creating one arms it; `execute` runs it now, with real side effects."
    },
    {
      "name": "Search",
      "description": "Permission-scoped search. Results are filtered by what the caller can access before ranking."
    },
    {
      "name": "Runners",
      "description": "Self-hosted runners: an outbound-only daemon on your own infrastructure that executes only the commands you declare in a local manifest. No SSH key, no inbound port, revocable at any time. See https://docs.lithora.app/developers/runner."
    },
    {
      "name": "Cycles",
      "description": "Cycles (sprints): plan a cycle from the backlog, open it, amend it, close it, and read velocity. The API calls the object a cycle and the URL calls it a sprint; `cycle_id` is the id every response carries."
    },
    {
      "name": "Webhooks",
      "description": "Outbound webhooks — how something outside Lithora learns that work changed. The signing secret is returned on create and on rotation, and never again."
    },
    {
      "name": "Bulk Data",
      "description": "Bulk export and import. Import WRITES and has no dry-run mode; rehearse against a scratch workspace."
    },
    {
      "name": "Custom Fields",
      "description": "Custom field DEFINITIONS and the VALUES bound to a task or project. Two collections behind one prefix: `/api/custom-fields` manages the definition (its name, type, option list), `/api/custom-fields/values` manages what a particular task or project holds for it. A definition belongs to exactly one team and can only be bound to entities inside that team."
    },
    {
      "name": "Saved Views",
      "description": "Named filter/sort/column presets over tasks and projects. A view is private to its owner unless `is_shared` is set, and a shared view reaches the members of its `team_id` and nobody else. Only the owner may update, delete or re-default a view — being able to SEE a shared view never confers the ability to change it."
    },
    {
      "name": "Activity",
      "description": "The append-only timeline of who did what to which entity. Read-only over the API; rows are written by the mutations themselves."
    },
    {
      "name": "Audit Log",
      "description": "The tamper-evident record of what every member of your teams did. Distinct from `Activity`, and the difference is the whole point of publishing both: an activity row is a best-effort feed entry with no before/after state, an audit entry is hash-chained to the one before it and carries the actor, the IP, the entity and the details. Reading it needs the Scale-tier `audit_logs` entitlement AND team-admin rights on the teams whose logs you are asking for. The operator routes that verify the chain and stream fleet-wide security events are deliberately NOT part of this contract — they read across tenants."
    },
    {
      "name": "Notes",
      "description": "Free-form notes attached to a project. A note is visible to the members of the project's team and to its creator; guests on a team are read-only and are refused every write here."
    },
    {
      "name": "Task Comments",
      "description": "The comment thread on a task or one of its subtasks. Replies nest one level below a top-level comment; a reply to a reply is refused. Access is inherited from the task, so anyone who can read the task can read its thread."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/api/ai-workspace/actions/confirm": {
      "post": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "Confirm a legacy intent-parser action plan (deprecated)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "session_id",
                  "action_id"
                ],
                "properties": {
                  "session_id": {
                    "type": "string"
                  },
                  "action_id": {
                    "type": "string"
                  },
                  "confirmed": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Action result",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "success",
                    "action_id",
                    "message"
                  ],
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "action_id": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    },
                    "details": {
                      "type": [
                        "object",
                        "null"
                      ],
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "deprecated": true,
        "description": "Deprecated. This is the pre-agent intent-parser gate, kept for backward compatibility. Use `POST /api/ai-workspace/agent/confirm` instead."
      }
    },
    "/api/ai-workspace/agent/confirm": {
      "post": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "Approve or reject a staged agent plan (the real confirmation gate)",
        "description": "When `POST /api/ai-workspace/chat` returns `requires_confirmation: true`, the agent has PROPOSED mutations and written nothing. This endpoint is the only place those writes happen.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "session_id",
                  "action_id"
                ],
                "properties": {
                  "session_id": {
                    "type": "string"
                  },
                  "action_id": {
                    "type": "string",
                    "description": "`action_plan.action_id` from the chat response."
                  },
                  "confirmed": {
                    "type": "boolean",
                    "default": true,
                    "description": "`false` discards the staged plan without executing it."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Plan applied or discarded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/ai-workspace/agent/pending": {
      "get": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "List staged-but-unapproved agent plans",
        "description": "Plans an autonomous run (e.g. overnight triage or a CI-failure webhook) staged for your approval.",
        "responses": {
          "200": {
            "description": "Pending plans",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/ai-workspace/chat": {
      "post": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "Send a message to the agent within a session",
        "description": "Returns the assistant response plus an optional `action_plan`. When `requires_confirmation` is true the plan is NOT executed until confirmed via `/api/ai-workspace/actions/confirm`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChatRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Assistant response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChatResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/ai-workspace/search": {
      "get": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "Search linkable items",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Search results",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "item_type": {
                        "type": "string"
                      },
                      "item_id": {
                        "type": "string"
                      },
                      "title": {
                        "type": "string"
                      },
                      "project_name": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "team_name": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "status": {
                        "type": [
                          "string",
                          "null"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/ai-workspace/sessions": {
      "post": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "Create an AI workspace session",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Optional session title"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created session",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "session_id",
                    "title",
                    "created_at"
                  ],
                  "properties": {
                    "session_id": {
                      "type": "string"
                    },
                    "title": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "get": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "List the caller's AI sessions",
        "description": "Sessions belonging to this account, newest first. A session is the unit that carries conversation history and linked work-graph context.",
        "responses": {
          "200": {
            "description": "AI sessions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "One AI workspace session."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/ai-workspace/sessions/{session_id}": {
      "get": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "Fetch one session with its messages",
        "description": "The session, its message history and the work-graph items linked into it as context.",
        "parameters": [
          {
            "name": "session_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The session's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The session and its messages.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/ai-workspace/sessions/{session_id}/link": {
      "parameters": [
        {
          "name": "session_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "AI Workspace"
        ],
        "summary": "Link an item into the session context",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "item_type",
                  "item_id"
                ],
                "properties": {
                  "item_type": {
                    "type": "string",
                    "enum": [
                      "task",
                      "project",
                      "whiteboard",
                      "file",
                      "note",
                      "idea"
                    ]
                  },
                  "item_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Linked",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/audit/correlate/{correlation_id}": {
      "get": {
        "tags": [
          "Audit Log"
        ],
        "summary": "Every event from one request",
        "description": "Returns every audit entry sharing a correlation id, oldest first, scoped the same way as `/api/audit/logs`. Take the id from an entry you have already fetched: `correlation_id` defaults to the entry's own `audit_id`, and nothing wires the request-scoped `X-Correlation-ID` response header into the audit logger, so an id lifted off a response you hold returns nothing. An id you cannot see returns an empty list with `count: 0`, not a 404 — the existence of another tenant's correlation id is not something this endpoint will confirm.",
        "parameters": [
          {
            "name": "correlation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The `correlation_id` of an entry you already hold, not the `X-Correlation-ID` header."
          }
        ],
        "responses": {
          "200": {
            "description": "Correlated events",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CorrelatedEvents"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not a team admin, or the team is not on a Scale plan. The body carries `upgrade_required: true` and `feature: \"audit_logs\"` in the entitlement case.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/audit/export": {
      "post": {
        "tags": [
          "Audit Log"
        ],
        "summary": "Export audit logs for compliance",
        "description": "Returns the rows INLINE in `data` — there is no job, no download URL and no paging, and the query behind it is capped at **10,000 rows**. An export whose `record_count` is exactly 10000 was truncated and you must narrow the window and export again; nothing in the response says so on its own.\n\n`format` is recorded and echoed but does not change the body: both `json` and `csv` return the same JSON array, so a caller asking for CSV has to render it. That is a known wart, kept documented rather than quietly fixed, because clients already depend on the current shape.\n\nThe export itself is written to the audit log at HIGH severity before the rows are read, so exporting is not a way to read the log without leaving a trace.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AuditExportRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The export",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditExport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not a team admin, or the team is not on a Scale plan. The body carries `upgrade_required: true` and `feature: \"audit_logs\"` in the entitlement case.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/audit/features": {
      "get": {
        "tags": [
          "Audit Log"
        ],
        "summary": "The filter vocabularies",
        "description": "The only route in this group that any authenticated caller may use — it enumerates names, never events, so it needs neither team-admin rights nor the Scale entitlement. Fetch it once at start-up and use it to validate the `feature`, `action` and `severity` filters before sending them.",
        "responses": {
          "200": {
            "description": "The vocabularies",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditableFeatures"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/audit/logs": {
      "get": {
        "tags": [
          "Audit Log"
        ],
        "summary": "Query the audit log",
        "description": "Newest first, scoped to the teams you administer. Filters combine with AND, and an unrecognised `feature`/`action`/`severity` value is **dropped rather than rejected** — so a typo widens the query silently instead of failing it. Validate against `GET /api/audit/features`.\n\nA caller who administers no team never reaches the query: the guard answers 403. An empty `logs` array therefore means the log is empty for the teams you administer, not that you were denied.",
        "parameters": [
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Only entries by this actor."
          },
          {
            "name": "feature",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Vocabulary from `GET /api/audit/features`."
          },
          {
            "name": "action",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Vocabulary from `GET /api/audit/features`."
          },
          {
            "name": "severity",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Vocabulary from `GET /api/audit/features`."
          },
          {
            "name": "from_time",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Inclusive lower bound on `timestamp`. Bounds are compared LEXICOGRAPHICALLY against the stored value, which is a naive UTC ISO-8601 string (`2026-08-25T04:19:33.123456`) rather than a BSON date. Send the same shape: no `Z`, no offset. A trailing `Z` sorts ABOVE the fractional seconds of the same instant, so `to_time=2026-08-25T00:00:00Z` quietly includes an extra second and `from_time` with a `Z` quietly excludes one — the query succeeds either way and nothing tells you the boundary moved."
          },
          {
            "name": "to_time",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Inclusive upper bound on `timestamp`. See `from_time` for how the comparison is made."
          },
          {
            "name": "ip_address",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Exact match on the recorded caller address."
          },
          {
            "name": "entity_type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Restrict to one kind of entity."
          },
          {
            "name": "entity_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Everything that ever happened to one entity. Pair with `entity_type`."
          },
          {
            "name": "correlation_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Exact match on the entry's `correlation_id`, which for most entries is that entry's own `audit_id` — see the field note on `AuditLogEntry`. `GET /api/audit/correlate/{correlation_id}` answers the same question without paging."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 500
            },
            "description": "Clamped to 500; a larger value is silently reduced rather than rejected."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Rows to skip. Wins over both `skip` and `page` when more than one is sent."
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "description": "1-indexed convenience; ignored when `offset` or `skip` is given."
          },
          {
            "name": "skip",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "This endpoint's original spelling of `offset`, still accepted so existing callers keep working, and still what the RESPONSE echoes back. New code should send `offset`."
          }
        ],
        "responses": {
          "200": {
            "description": "One page of entries",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditLogPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not a team admin, or the team is not on a Scale plan. The body carries `upgrade_required: true` and `feature: \"audit_logs\"` in the entitlement case.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/audit/logs/{audit_id}": {
      "get": {
        "tags": [
          "Audit Log"
        ],
        "summary": "One audit entry",
        "description": "Returns 404 for an id outside the teams you administer as well as for one that does not exist — the two are deliberately indistinguishable.",
        "parameters": [
          {
            "name": "audit_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The entry",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditLogEntry"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not a team admin, or the team is not on a Scale plan. The body carries `upgrade_required: true` and `feature: \"audit_logs\"` in the entitlement case.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/audit/stats": {
      "get": {
        "tags": [
          "Audit Log"
        ],
        "summary": "Audit counts over a trailing window",
        "description": "Aggregates over the teams you administer, or over one of them with `team_id`.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "One team you administer. A team you do not is 403 — unlike the entity routes, this one distinguishes."
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 30,
              "minimum": 1,
              "maximum": 365
            },
            "description": "Trailing window. Outside 1–365 is a 422, not a clamp."
          }
        ],
        "responses": {
          "200": {
            "description": "Counts",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditStats"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not a team admin, or the team is not on a Scale plan. The body carries `upgrade_required: true` and `feature: \"audit_logs\"` in the entitlement case.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/login": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Log in (may return an auth challenge instead of a token)",
        "description": "On success returns `{ token, requires_2fa:false, requires_login_otp:false, user }`. If the account has 2FA enabled the response is `{ requires_2fa:true, email }` with NO token. If the device is untrusted the response is `{ requires_login_otp:true, email }` with NO token (an OTP is emailed). SDKs should detect those challenge shapes and raise rather than assume a token was returned.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either a token, or an auth challenge (2FA / login OTP) with no token.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/AuthSuccess"
                    },
                    {
                      "$ref": "#/components/schemas/AuthChallenge"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/auth/me": {
      "get": {
        "tags": [
          "Auth"
        ],
        "summary": "Get the current authenticated user",
        "responses": {
          "200": {
            "description": "Current user object",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/auth/tokens": {
      "post": {
        "tags": [
          "Auth Tokens"
        ],
        "summary": "Mint a personal access token",
        "description": "Returns the secret exactly once in the `token` field — store it immediately.\n\nRejected with `403` when the caller is itself authenticated with a PAT (a token can never mint another token) or is being impersonated by an admin. Rejected with `400` when no scope is selected or when the caller already holds 50 active tokens.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TokenCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token minted; secret shown once",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenCreated"
                }
              }
            }
          },
          "400": {
            "description": "Unknown scope, no scope selected, or token limit reached",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is PAT-authenticated or impersonated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Auth Tokens"
        ],
        "summary": "List your personal access tokens",
        "description": "Masked — the secret is never returned. Scoped to the caller.",
        "responses": {
          "200": {
            "description": "Tokens",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "tokens"
                  ],
                  "properties": {
                    "tokens": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Token"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/auth/tokens/scopes": {
      "get": {
        "tags": [
          "Auth Tokens"
        ],
        "summary": "Catalog of grantable scopes",
        "description": "Drives the scope picker in the web app. Returns `{key, label}` pairs.",
        "responses": {
          "200": {
            "description": "Scope catalog",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "scopes": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "$ref": "#/components/schemas/TokenScope"
                          },
                          "label": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/auth/tokens/{token_id}": {
      "parameters": [
        {
          "name": "token_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          },
          "description": "The `token_id` (not the secret)."
        }
      ],
      "delete": {
        "tags": [
          "Auth Tokens"
        ],
        "summary": "Revoke a personal access token",
        "description": "Immediate and irreversible. Rejected with `403` for PAT-authenticated callers.",
        "responses": {
          "200": {
            "description": "Revoked",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "token_id": {
                      "type": "string"
                    },
                    "revoked": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is PAT-authenticated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List automations",
        "description": "Every automation visible to the caller. An automation is a trigger, an optional condition and one or more actions; `is_active` decides whether the trigger is armed. Scope with `team_id` or `project_id`.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to a team you belong to."
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to a project you can access."
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's automations.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "An automation definition."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Create an automation",
        "description": "`trigger_type` names the event that arms it (for example `task.created`, `github.pull_request`, or `schedule`). A `schedule` trigger REQUIRES `schedule_cron` — a schedule automation with no cron expression is created successfully and never fires, which is the failure this note exists to prevent.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "trigger_type"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "trigger_type": {
                    "type": "string",
                    "description": "The event that arms the automation."
                  },
                  "condition": {
                    "type": "string",
                    "description": "Optional expression gating the actions."
                  },
                  "actions": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "is_active": {
                    "type": "boolean",
                    "default": true
                  },
                  "schedule_cron": {
                    "type": "string",
                    "description": "Required when trigger_type is 'schedule'."
                  },
                  "team_id": {
                    "type": "string"
                  },
                  "project_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created automation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/automations/actions/types": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List action types",
        "description": "Every action the engine can dispatch, plus the integration actions available to the CALLING user annotated with their connected/available state and any missing OAuth scopes. The list is derived from the executor's handler map rather than hand-maintained, so it cannot advertise an action that is not implemented or omit one that is.",
        "responses": {
          "200": {
            "description": "The action catalogue for this caller.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "actions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "description": "An action type."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/automations/approvals": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List runs waiting for approval",
        "description": "Runs paused at a `require_approval` gate across every team the caller belongs to. The staged payload and remaining actions are deliberately NOT returned — they stay server-side and are replayed by the approve endpoint. Each row carries whether the gate has already lapsed.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 200
            },
            "description": "Rows to return."
          }
        ],
        "responses": {
          "200": {
            "description": "Pending approvals.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "approvals": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "description": "A paused run."
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/automations/dead-letter": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List automation work that gave up",
        "description": "Runs that ended FAILED, scoped to the caller's own teams — every action-level failure lands here, as does a dispatch the queue stopped retrying. Test runs are excluded: a customer testing is not the platform dropping work. Each row carries the `automation_id` needed to replay it.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Restrict to one of your teams. Omit for all of them."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 200
            },
            "description": "Rows to return."
          }
        ],
        "responses": {
          "200": {
            "description": "Failed runs, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "entries": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "description": "A failed run."
                      }
                    },
                    "total": {
                      "type": "integer"
                    },
                    "showing": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/automations/dead-letter/{run_id}/retry": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Replay a failed run",
        "description": "A retry button is an execute button wearing a different label: this dispatches the automation's REAL actions again, so it carries the same rate limit and the same permission check as manual execute. Run documents cap the payload they store, so a replay of a run whose event was truncated is refused rather than re-run against a summary.",
        "parameters": [
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The failed run's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The replay result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "description": "Execution rate limit exceeded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/automations/runs/{run_id}/approve": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Approve a paused run",
        "description": "Releases the staged actions and runs them as the automation's ORIGINAL creator — approving authorises WHEN and WHETHER, never WHO. Requires the role the gate named (`manager` by default). An expired gate fails closed with 410 rather than running late. Approving decides the fate of the staged tail only: actions that already failed before the gate stay failed.",
        "parameters": [
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The paused run's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The decision and the resulting run state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "410": {
            "description": "The approval gate expired before the decision.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/automations/runs/{run_id}/reject": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Reject a paused run",
        "description": "Discards the staged actions. Requires the same role as approving. Like approve, this decides the staged tail only — a run that failed two actions before reaching the gate is not recorded as a clean skip.",
        "parameters": [
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The paused run's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The rejection and the resulting run state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/secrets": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List a team's automation secrets",
        "description": "NAMES ONLY — values are never returned by any endpoint, including this one. Requires team admin. Reference a secret from an action with the `{{secret:NAME}}` handle.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The team whose vault to list."
          }
        ],
        "responses": {
          "200": {
            "description": "The secret names.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "team_id": {
                      "type": "string"
                    },
                    "secrets": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Store an automation secret",
        "description": "Creates or overwrites a named secret in the team vault. Requires team admin. The response never echoes the value.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "team_id",
                  "name",
                  "value"
                ],
                "properties": {
                  "team_id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 80,
                    "description": "Referenced from actions as {{secret:NAME}}."
                  },
                  "value": {
                    "type": "string",
                    "minLength": 1
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "saved": {
                      "type": "boolean"
                    },
                    "name": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/automations/secrets/{name}": {
      "delete": {
        "tags": [
          "Automations"
        ],
        "summary": "Delete an automation secret",
        "description": "REFUSED while a live automation still references the handle, and the refusal names the automations so they can be unpicked rather than guessed at — an unresolved `{{secret:NAME}}` used to be sent as an empty header value, so deleting one secret broke every automation using it and surfaced hours later as somebody else's 401. `force=true` is the escape hatch for a leaked secret that must go now, which is the one case where breaking those automations is the point. Requires team admin.",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The secret's name."
          },
          {
            "name": "team_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The team that owns the secret."
          },
          {
            "name": "force",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Delete even though live automations reference it."
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    },
                    "name": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Live automations still reference this secret; they are named in the response.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/automations/simulate-draft": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Backtest an unsaved automation",
        "description": "The same dry run as the per-automation simulate, for a candidate that has not been created yet — post the body you would send to `POST /api/automations`, plus a replay `limit`. Nothing is stored and no action is executed. Declared ahead of the `/{automation_id}` routes so the literal path is not captured as an id.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "trigger_type"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "default": "Draft automation"
                  },
                  "trigger_type": {
                    "type": "string"
                  },
                  "condition": {
                    "type": "string"
                  },
                  "actions": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "team_id": {
                    "type": "string"
                  },
                  "project_id": {
                    "type": "string"
                  },
                  "limit": {
                    "type": "integer",
                    "default": 50,
                    "minimum": 1,
                    "maximum": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The simulation report.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "What WOULD have run: matched events, evaluated conditions and the actions each match would have dispatched."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/automations/templates": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List the automation template gallery",
        "description": "Ready-made automation definitions to start from. Reading the gallery creates nothing; POST the definition you want to `/api/automations`.",
        "responses": {
          "200": {
            "description": "Template definitions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "A template automation definition."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/automations/templates/{template_id}/instantiate": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Create an automation from a template",
        "description": "Installs a gallery template as a live automation. Send `team_id` to choose which of your teams it lands in; without one it installs into your first team. A template whose required capabilities are not available is refused with 409 rather than installed dead.",
        "parameters": [
          {
            "name": "template_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The template's id, from the templates listing."
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "team_id": {
                    "type": "string",
                    "description": "Team to install into. Must be one you belong to."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The created automation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "201": {
            "description": "The created automation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The template requires capabilities this workspace does not have.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/automations/triggers/types": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List trigger types",
        "description": "The events an automation can be armed on, with the fields each one puts in the event payload. Use this rather than hard-coding trigger names: a name that is not in this list is accepted on create and never fires, because the dispatcher matches the published name exactly and has no alias on read.",
        "responses": {
          "200": {
            "description": "The trigger catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "triggers": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "description": "A trigger type."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/automations/{automation_id}": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "Fetch one automation",
        "description": "The full stored definition, including its actions and current `is_active` state.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The automation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "Automations"
        ],
        "summary": "Replace an automation",
        "description": "Full update, restricted to the automation's creator. Legacy underscore trigger names (`task_created`) are normalised to their published form on write, the same as create. Prefer PATCH for partial edits — it is team-aware where this is owner-only.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Every field is optional; omitted fields keep their stored value.",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "trigger_type": {
                    "type": "string",
                    "description": "The event that arms the automation."
                  },
                  "condition": {
                    "type": "string",
                    "description": "Optional expression gating the actions."
                  },
                  "actions": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "is_active": {
                    "type": "boolean"
                  },
                  "schedule_cron": {
                    "type": "string",
                    "description": "Required when trigger_type is 'schedule'."
                  },
                  "project_id": {
                    "type": "string"
                  },
                  "team_id": {
                    "type": "string",
                    "description": "Must be a team you belong to. Re-homing an automation into a team you are not a member of is refused."
                  },
                  "notify_in_app": {
                    "type": "boolean"
                  },
                  "notify_email": {
                    "type": "boolean"
                  },
                  "idempotency_key_fields": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated automation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "Automations"
        ],
        "summary": "Partially update an automation",
        "description": "Applies only the fields present in the body. Unlike PUT this is team-aware — any member of the owning team may edit a shared automation — and the heavier definition-level permission check runs only when the trigger, actions, branches or team are actually being changed. An empty body is a no-op and returns the current state.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Every field is optional; omitted fields keep their stored value.",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "trigger_type": {
                    "type": "string",
                    "description": "The event that arms the automation."
                  },
                  "condition": {
                    "type": "string",
                    "description": "Optional expression gating the actions."
                  },
                  "actions": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "is_active": {
                    "type": "boolean"
                  },
                  "schedule_cron": {
                    "type": "string",
                    "description": "Required when trigger_type is 'schedule'."
                  },
                  "project_id": {
                    "type": "string"
                  },
                  "team_id": {
                    "type": "string",
                    "description": "Must be a team you belong to. Re-homing an automation into a team you are not a member of is refused."
                  },
                  "notify_in_app": {
                    "type": "boolean"
                  },
                  "notify_email": {
                    "type": "boolean"
                  },
                  "idempotency_key_fields": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated automation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Automations"
        ],
        "summary": "Delete an automation",
        "description": "A SOFT delete: the definition is marked deleted and disarmed, and its run history is retained for audit. Restricted to the automation’s creator like PUT, and not team-aware like PATCH — for anyone else the automation is simply not found, so the refusal arrives as 404 rather than 403. There is no undelete endpoint — recreate from an export, or roll back to a stored version before deleting.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion acknowledged.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/execute": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Run an automation now",
        "description": "Executes the actions immediately, ignoring the trigger and the schedule. The condition is still evaluated. This performs REAL side effects — it is not a dry run.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The run record for this execution.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/export": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Export the definition as YAML",
        "description": "Returns the automation as a portable YAML document for review or version control. POST rather than GET because export is recorded against the automation.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The exported definition.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "yaml": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/history": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "Read an automation's execution history",
        "description": "The transparency log for one automation — what fired, when, and what it did. Page backwards with the `before` cursor rather than raising `limit`; the ceiling is deliberate because this collection is written by every run.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 200
            },
            "description": "Rows per page."
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO timestamp cursor — only entries older than this."
          }
        ],
        "responses": {
          "200": {
            "description": "History entries, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "history": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "description": "A history entry."
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/import": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Import a definition into an automation",
        "description": "Workflows-as-code: apply a YAML or JSON definition to an existing automation. The prior state is snapshotted as a version FIRST, so an import is always reversible through rollback. Send exactly one of `yaml` or `definition`; an invalid definition is rejected with 400 and nothing is written. This is the inverse of the export endpoint.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "yaml": {
                    "type": "string",
                    "description": "Automation definition as YAML."
                  },
                  "definition": {
                    "type": "object",
                    "description": "Automation definition as a JSON object."
                  },
                  "note": {
                    "type": "string",
                    "description": "Optional change note recorded on the prior-state version."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The imported definition and the version it superseded.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/runs": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List an automation's runs",
        "description": "Run records newest first: when it fired, what the condition decided, and what each action returned.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "responses": {
          "200": {
            "description": "Run records.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "One execution of the automation."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/runs/{run_id}": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "Fetch one run record",
        "description": "The full record for a single execution, including per-action outcomes — the place to look when an automation appeared to fire but nothing changed.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          },
          {
            "name": "run_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The run's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The run record.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/simulate": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Backtest an automation against recent events",
        "description": "Replays recent REAL events for the team through this automation and reports what would have run. No action is executed, nothing is mutated and no outbound request is made, so this is safe to call against a live automation. Post a `definition` to backtest a candidate change without saving it.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "limit": {
                    "type": "integer",
                    "default": 50,
                    "minimum": 1,
                    "maximum": 500,
                    "description": "How many recent events to replay."
                  },
                  "definition": {
                    "type": "object",
                    "description": "Candidate definition to backtest instead of the stored one (trigger_type, condition, actions, branches)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The simulation report.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "What WOULD have run: matched events, evaluated conditions and the actions each match would have dispatched."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/test": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Test run an automation with mock data",
        "description": "Records a run marked `is_test`, so it stays out of the dead-letter list and the run statistics. READ `execute_actions` BEFORE USING THIS: left false (the default) nothing is dispatched; set true and this runs the identical production path — real webhooks, real integration writes, real model spend — off a query-string boolean. It is rate-limited exactly like manual execute for that reason.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          },
          {
            "name": "execute_actions",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Actually dispatch the actions. Real side effects."
          }
        ],
        "responses": {
          "200": {
            "description": "The test run.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "description": "Execution rate limit exceeded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/automations/{automation_id}/toggle": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Arm or disarm an automation",
        "description": "Flips `is_active`. Disarming does not delete the definition or its run history; the trigger simply stops firing.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "responses": {
          "200": {
            "description": "The automation, with its new is_active state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/versions": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "List definition versions",
        "description": "Every saved version of the definition, newest first. Editing an automation writes a new version rather than overwriting the old one, so a change can be inspected and undone.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          }
        ],
        "responses": {
          "200": {
            "description": "Definition versions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "One stored version."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/versions/{version_number}/diff": {
      "get": {
        "tags": [
          "Automations"
        ],
        "summary": "Diff a stored version",
        "description": "Compares a stored version against the current definition, or against the version before it with `against=previous`. Pair with the rollback endpoint: diff first, roll back second.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          },
          {
            "name": "version_number",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The stored version to diff."
          },
          {
            "name": "against",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "current",
                "previous"
              ],
              "default": "current"
            },
            "description": "What to compare the version against."
          }
        ],
        "responses": {
          "200": {
            "description": "The diff.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "from": {
                      "type": "string"
                    },
                    "to": {
                      "type": "string"
                    },
                    "diff": {
                      "type": "object",
                      "description": "Field-level changes."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/automations/{automation_id}/versions/{version_number}/rollback": {
      "post": {
        "tags": [
          "Automations"
        ],
        "summary": "Roll back to an earlier version",
        "description": "Restores a previous definition. The rollback is itself recorded as a new version, so the history stays append-only and the state before the rollback remains recoverable.",
        "parameters": [
          {
            "name": "automation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The automation's id."
          },
          {
            "name": "version_number",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "The version to restore."
          }
        ],
        "responses": {
          "200": {
            "description": "The automation, at the restored definition.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/github/status": {
      "get": {
        "tags": [
          "GitHub"
        ],
        "summary": "GitHub connection status for the caller",
        "description": "Whether this account has a working GitHub connection, and which identity it is connected as. Note the prefix: status is served from `/api/github/`, while `connect` and `repos` live under `/api/integrations/github/`. The split is historical and easy to get wrong — both Lithora SDKs called `/api/integrations/github/status` for a while and got a bare 404.",
        "responses": {
          "200": {
            "description": "Connection status.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "connected": {
                      "type": "boolean"
                    },
                    "username": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The connected GitHub login, when connected."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/github/webhooks": {
      "post": {
        "tags": [
          "GitHub"
        ],
        "summary": "Inbound GitHub webhook receiver",
        "description": "HMAC-verified (header `X-Hub-Signature-256: sha256=<hex>` over the raw body, secret `GITHUB_WEBHOOK_SECRET`). Fail-closed: if the secret is unset or the signature is missing/invalid the request is rejected with 401. The body is the raw GitHub event payload; the event type is read from the `X-GitHub-Event` header. Supported source events are normalized into the `github.*` event names listed in webhooks.md. This endpoint is authenticated by HMAC, not by the JWT Bearer token.",
        "security": [],
        "parameters": [
          {
            "name": "X-Hub-Signature-256",
            "in": "header",
            "required": true,
            "description": "HMAC SHA-256 signature, formatted 'sha256=<hex>'.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-GitHub-Event",
            "in": "header",
            "required": true,
            "description": "GitHub event type, e.g. 'issues', 'pull_request', 'push', 'release', 'check_suite', or 'ping'.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Raw GitHub webhook event payload.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted (event queued for processing) or ping acknowledged.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "received": {
                      "type": "boolean"
                    },
                    "event": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "ping": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid JSON payload",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing signature",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/integrations/github/repos": {
      "get": {
        "tags": [
          "GitHub"
        ],
        "summary": "List repositories the connection can see",
        "description": "Repositories reachable with the caller's stored GitHub credential. Returns an empty list rather than an error when GitHub is not connected — check `/api/integrations/github/status` to tell 'not connected' from 'connected, no repositories'.",
        "parameters": [
          {
            "name": "org",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Limit to one organisation."
          }
        ],
        "responses": {
          "200": {
            "description": "Accessible repositories.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "A GitHub repository."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/projects": {
      "get": {
        "tags": [
          "Projects"
        ],
        "summary": "List projects visible to the caller",
        "responses": {
          "200": {
            "description": "Array of projects",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Project"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "description": "Restrict to one team. A team you are not a member of returns an empty array rather than a 403.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size. Defaults to 500, clamped to 2000.",
            "schema": {
              "type": "integer",
              "default": 500,
              "maximum": 2000
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Rows to skip.",
            "schema": {
              "type": "integer",
              "default": 0
            }
          }
        ]
      },
      "post": {
        "tags": [
          "Projects"
        ],
        "summary": "Create a project",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProjectCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Project created (ActionReceipt; project in `data`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActionReceipt"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is not a member of the team",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "description": "Returns an ActionReceipt envelope — read the entity from `data`."
      }
    },
    "/api/projects/{project_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "get": {
        "tags": [
          "Projects"
        ],
        "summary": "Get a project by id",
        "responses": {
          "200": {
            "description": "Project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "Projects"
        ],
        "summary": "Replace a project",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProjectUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Project updated (ActionReceipt; project in `data`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActionReceipt"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "description": "Returns an ActionReceipt envelope — read the entity from `data`."
      },
      "patch": {
        "tags": [
          "Projects"
        ],
        "summary": "Partially update a project",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProjectUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Project updated (ActionReceipt; project in `data`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActionReceipt"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "description": "Returns an ActionReceipt envelope — read the entity from `data`."
      },
      "delete": {
        "tags": [
          "Projects"
        ],
        "summary": "Move a project to Trash (soft delete)",
        "responses": {
          "200": {
            "description": "Moved to Trash with its tasks",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SoftDeleteResult"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "retention_days": {
                          "type": "integer"
                        },
                        "deleted": {
                          "type": "object",
                          "properties": {
                            "project": {
                              "type": "integer"
                            },
                            "tasks": {
                              "type": "integer"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "description": "Soft delete. The project and every task inside it are stamped as trashed and vanish from all reads; both are restorable via `restore_url` for `retention_days`."
      }
    },
    "/api/runners": {
      "post": {
        "tags": [
          "Runners"
        ],
        "summary": "Register a runner and return its one-time enrollment token",
        "description": "Team-admin only: the manifest defines what Lithora may execute on your infrastructure, which is an administrative act rather than a member one. `enrollment_token` is shown ONCE and cannot be retrieved again.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "team_id",
                  "name",
                  "manifest"
                ],
                "properties": {
                  "team_id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 80
                  },
                  "manifest": {
                    "type": "array",
                    "description": "The commands this runner may execute. Each entry needs at least a `name`; `run` must be an argv ARRAY, never a string, because a string implies a shell and the daemon rejects it at load time. `working_dir`, `env` and `timeout` are accepted and NOT stored — the daemon reads those from the manifest file on the customer's own host, and keeping them here would let someone edit a box in Lithora and believe they had changed what executes on their machine. They are named back in `ignored_fields` so the drop is visible.",
                    "items": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The runner record, its one-time enrollment token, and the fields the server refused to store.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "get": {
        "tags": [
          "Runners"
        ],
        "summary": "List the runners registered to a team",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": true,
            "description": "The team the runner belongs to. Required even where it looks redundant: the server scopes every lookup by it rather than trusting an id alone, so an id from another tenant returns 404 — which job or runner ids exist is not information a stranger is owed.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Every runner on the team, with its status and last-seen time.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/runners/audit": {
      "get": {
        "tags": [
          "Runners"
        ],
        "summary": "Your own immutable record of everything Lithora asked your runner to do",
        "description": "Includes the ATTEMPTS, not only what ran. A row names `impersonator` whenever the request came from a Lithora operator wearing a customer identity, which is what makes this your record rather than your employees' alibi.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": true,
            "description": "The team the runner belongs to. Required even where it looks redundant: the server scopes every lookup by it rather than trusting an id alone, so an id from another tenant returns 404 — which job or runner ids exist is not information a stranger is owed.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Audit events, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/runners/jobs": {
      "get": {
        "tags": [
          "Runners"
        ],
        "summary": "The team's runner job history, newest first",
        "description": "Paged by KEYSET, not offset: pass the ISO `created_at` of the last row of the previous page as `before`. Rows are inserted at the head of this sort, so an offset page shifts under the reader and would show the same job twice while hiding another.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": true,
            "description": "The team the runner belongs to. Required even where it looks redundant: the server scopes every lookup by it rather than trusting an id alone, so an id from another tenant returns 404 — which job or runner ids exist is not information a stranger is owed.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "description": "ISO `created_at` of the last row of the previous page.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of jobs, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/runners/{runner_id}/dispatch": {
      "post": {
        "tags": [
          "Runners"
        ],
        "summary": "Queue a manifest-declared command on a runner",
        "description": "`command` must name an entry in the runner's manifest. It is validated here, before the job is queued, and AGAIN by the daemon against the manifest file on its own disk — two independent checks, because the second is what holds if this server is wrong or compromised. Rate limited.",
        "parameters": [
          {
            "name": "runner_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": true,
            "description": "The team the runner belongs to. Required even where it looks redundant: the server scopes every lookup by it rather than trusting an id alone, so an id from another tenant returns 404 — which job or runner ids exist is not information a stranger is owed.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "command"
                ],
                "properties": {
                  "command": {
                    "type": "string",
                    "description": "A command name declared in this runner's manifest."
                  },
                  "args": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Substituted as whole argv elements, never spliced into a larger string, so an argument cannot become a second command."
                  },
                  "confirm": {
                    "type": "string",
                    "description": "Required for commands the manifest marks as needing confirmation."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The queued job.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/runners/{runner_id}/manifest": {
      "patch": {
        "tags": [
          "Runners"
        ],
        "summary": "Replace the command list a runner is permitted to execute",
        "description": "Wholesale replacement, not a merge: the body is the complete new list. Team-admin only, for the same reason enrolment is — the manifest IS your exposure, and widening it decides what Lithora can run on your machines.\n\nThis changes what Lithora will DISPATCH. It does not change what the host will RUN: the daemon checks every job against the manifest file on its own disk and refuses anything absent from it. Update that file too, or the runner logs a drift warning and refuses the dispatch.",
        "parameters": [
          {
            "name": "runner_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "team_id",
                  "commands"
                ],
                "properties": {
                  "team_id": {
                    "type": "string"
                  },
                  "commands": {
                    "type": "array",
                    "description": "The commands this runner may execute. Each entry needs at least a `name`; `run` must be an argv ARRAY, never a string, because a string implies a shell and the daemon rejects it at load time. `working_dir`, `env` and `timeout` are accepted and NOT stored — the daemon reads those from the manifest file on the customer's own host, and keeping them here would let someone edit a box in Lithora and believe they had changed what executes on their machine. They are named back in `ignored_fields` so the drop is visible.",
                    "items": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored manifest, plus a `note` naming any fields that were accepted and not kept.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/runners/{runner_id}/revoke": {
      "post": {
        "tags": [
          "Runners"
        ],
        "summary": "Revoke a runner",
        "description": "Takes effect on the runner's next poll. The credential is checked on every poll, so there is no window in which a revoked runner still accepts work. Team-admin only.",
        "parameters": [
          {
            "name": "runner_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": true,
            "description": "The team the runner belongs to. Required even where it looks redundant: the server scopes every lookup by it rather than trusting an id alone, so an id from another tenant returns 404 — which job or runner ids exist is not information a stranger is owed.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Confirmation that the runner was revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/search": {
      "post": {
        "tags": [
          "Search"
        ],
        "summary": "Search everything the caller may see",
        "description": "Permission-scoped full-text search across tasks, projects, notes, docs and files. Results are filtered by what the caller can access BEFORE ranking, so the total is the caller's total and not the workspace's. POST rather than GET so a query is not written into access logs or browser history.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "query"
                ],
                "properties": {
                  "query": {
                    "type": "string"
                  },
                  "limit": {
                    "type": "integer",
                    "description": "Maximum results to return."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ranked results, grouped by entity type.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/search/recent": {
      "get": {
        "tags": [
          "Search"
        ],
        "summary": "The caller's recent searches",
        "description": "Recent queries for this account only. Never another user's.",
        "responses": {
          "200": {
            "description": "Recent queries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "A previous search."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/search/suggestions": {
      "get": {
        "tags": [
          "Search"
        ],
        "summary": "Typeahead suggestions for a partial query",
        "description": "Suggestions drawn from entities the caller can already see, so this endpoint cannot be used to probe for the existence of work in another tenant.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The partial query typed so far."
          }
        ],
        "responses": {
          "200": {
            "description": "Suggestions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "A suggested completion."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/tasks": {
      "get": {
        "tags": [
          "Tasks"
        ],
        "summary": "List tasks for a project",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "List tasks in one project. Requires membership of the owning team.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "description": "List tasks across every project in one team. Ignored when project_id is set.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filter by status.",
            "schema": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress",
                "in_review",
                "done"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Array of tasks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "description": "Returns a nested subtask tree as a bare array. With no filter it spans every project you can see, which is rarely what you want — pass project_id or team_id. Not paginated; a single response is hard-capped at 10,000 tasks. Trashed tasks are never included."
      },
      "post": {
        "tags": [
          "Tasks"
        ],
        "summary": "Create a task",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaskCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created task",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Project or team not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tasks/bulk": {
      "post": {
        "tags": [
          "Tasks"
        ],
        "summary": "Create multiple tasks in one request",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tasks"
                ],
                "properties": {
                  "tasks": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/TaskCreate"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created tasks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/tasks/my": {
      "get": {
        "tags": [
          "Tasks"
        ],
        "summary": "List tasks assigned to the current user",
        "responses": {
          "200": {
            "description": "Array of tasks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/tasks/{task_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TaskId"
        }
      ],
      "get": {
        "tags": [
          "Tasks"
        ],
        "summary": "Get a task by id",
        "responses": {
          "200": {
            "description": "Task",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "Tasks"
        ],
        "summary": "Update a task",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaskCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged — the updated task is NOT returned",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Optimistic-concurrency conflict: the stored `version` moved on. Re-read the task and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "Tasks"
        ],
        "summary": "Partially update a task",
        "description": "Same handler as PUT; every field is optional. Send only what changes.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaskCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged — the updated task is NOT returned",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Optimistic-concurrency conflict: the stored `version` moved on. Re-read the task and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Tasks"
        ],
        "summary": "Move a task to Trash (soft delete)",
        "responses": {
          "200": {
            "description": "Moved to Trash; restorable via `restore_url`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SoftDeleteResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/tasks/{task_id}/github-repo": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TaskId"
        }
      ],
      "patch": {
        "tags": [
          "Tasks"
        ],
        "summary": "Link or update the GitHub repository for a task",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "github_repo_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Id of the linked GitHub repo (null to unlink)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated task",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/tasks/{task_id}/push-to-github": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TaskId"
        }
      ],
      "post": {
        "tags": [
          "Tasks"
        ],
        "summary": "Push the task to its linked GitHub repository (creates/updates an issue)",
        "responses": {
          "200": {
            "description": "Push result",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/tasks/{task_id}/status": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TaskId"
        }
      ],
      "patch": {
        "tags": [
          "Tasks"
        ],
        "summary": "Update a task's status",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "todo",
                      "in_progress",
                      "in_review",
                      "done"
                    ]
                  },
                  "version": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "The `version` you last read. Optional."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged — the updated task is NOT returned",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Version conflict — re-read the task and retry",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "description": "Status-only update. Send `version` to opt into optimistic concurrency: the write is rejected with 409 if the stored version has moved on. Omit it for last-write-wins."
      }
    },
    "/api/teams": {
      "get": {
        "tags": [
          "Teams"
        ],
        "summary": "List teams the caller belongs to",
        "responses": {
          "200": {
            "description": "Array of teams",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Team"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Teams"
        ],
        "summary": "Create a team",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TeamCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Team created (ActionReceipt; team in `data`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActionReceipt"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "description": "Returns an ActionReceipt envelope — read the entity from `data`."
      }
    },
    "/api/teams/{team_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TeamId"
        }
      ],
      "get": {
        "tags": [
          "Teams"
        ],
        "summary": "Get a team by id",
        "responses": {
          "200": {
            "description": "Team",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Team"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/teams/{team_id}/members": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TeamId"
        }
      ],
      "get": {
        "tags": [
          "Teams"
        ],
        "summary": "List members of a team",
        "responses": {
          "200": {
            "description": "Array of users",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/User"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/work-items": {
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "List work items",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Filter by entity type",
            "schema": {
              "$ref": "#/components/schemas/WorkItemType"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Array of work items",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/WorkItem"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Work Items"
        ],
        "summary": "Create a work item",
        "description": "Usually created internally when a task/project/whiteboard is created. `type`, `entity_id`, and `team_id` are required; `entity_id` references the underlying entity (task_id, project_id, etc.).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WorkItemCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created work item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkItem"
                }
              }
            }
          },
          "400": {
            "description": "Invalid type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "description": "Work item already exists for this entity",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/work-items/cycle-time": {
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "Native issue->PR-merge cycle-time analytics",
        "description": "Per-repo / per-project cycle-time medians plus a sample list of measured items (issue opened -> linked PR merged). Derived natively from GitHub PR-merge webhooks recorded on linked tasks (no external analytics add-on). Returns `overall`, `by_repo` and `by_project` summaries (count/median/p90/avg, in seconds and days) plus a recent `sample`. Filter with `team_id` and/or `project_id`.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to a team you belong to. team_id or project_id is required."
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to a project you can access. team_id or project_id is required."
          },
          {
            "name": "repo",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional 'owner/name' filter within the authorized scope."
          }
        ],
        "responses": {
          "200": {
            "description": "Cycle-time analytics",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "filters": {
                      "type": "object",
                      "properties": {
                        "team_id": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "project_id": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "repo": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    },
                    "overall": {
                      "$ref": "#/components/schemas/CycleTimeSummary"
                    },
                    "by_repo": {
                      "type": "object",
                      "additionalProperties": {
                        "$ref": "#/components/schemas/CycleTimeSummary"
                      }
                    },
                    "by_project": {
                      "type": "object",
                      "additionalProperties": {
                        "$ref": "#/components/schemas/CycleTimeSummary"
                      }
                    },
                    "sample": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "task_id": {
                            "type": "string"
                          },
                          "title": {
                            "type": "string"
                          },
                          "github_repo": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "github_issue_number": {
                            "type": [
                              "integer",
                              "null"
                            ]
                          },
                          "github_pr_number": {
                            "type": [
                              "integer",
                              "null"
                            ]
                          },
                          "merged_at": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "cycle_days": {
                            "type": [
                              "number",
                              "null"
                            ]
                          },
                          "cycle_time_source": {
                            "type": [
                              "string",
                              "null"
                            ]
                          }
                        }
                      }
                    },
                    "note": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/work-items/graph": {
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "The work graph as nodes and edges",
        "description": "Nodes (issues, PRs, docs, people) and the typed relations between them, for one team or one project. One of `team_id` or `project_id` is REQUIRED — an unscoped graph across every tenant is not something this endpoint will build.",
        "parameters": [
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to a team you belong to."
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to a project you can access."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer"
            },
            "description": "Maximum nodes to return."
          }
        ],
        "responses": {
          "200": {
            "description": "Graph nodes and edges.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "nodes": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "edges": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "node_count": {
                      "type": "integer"
                    },
                    "edge_count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/work-items/relations": {
      "post": {
        "tags": [
          "Work Items"
        ],
        "summary": "Create a typed relation between two work items",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RelationCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created relation",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Relation"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/work-items/relations/{relation_id}": {
      "parameters": [
        {
          "name": "relation_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "Work Items"
        ],
        "summary": "Delete a relation",
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/work-items/resolve-ref": {
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "Resolve a #N reference",
        "description": "Turns a `#123` PR or issue reference into the work item it points at, within the caller's visible scope. Returns 404 when the reference resolves to nothing the caller may see — deliberately the same answer as a reference that does not exist, so this cannot be used to enumerate another tenant's work.",
        "parameters": [
          {
            "name": "ref",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The reference, e.g. '#123'."
          }
        ],
        "responses": {
          "200": {
            "description": "The resolved work item.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/work-items/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WorkItemId"
        }
      ],
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "Get a work item by id",
        "responses": {
          "200": {
            "description": "Work item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkItem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "Work Items"
        ],
        "summary": "Update a work item",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WorkItemUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated work item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkItem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "No access to this work item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Work Items"
        ],
        "summary": "Delete a work item",
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/work-items/{id}/children": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WorkItemId"
        }
      ],
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "List child work items",
        "responses": {
          "200": {
            "description": "Array of work items",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/WorkItem"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/work-items/{id}/parents": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WorkItemId"
        }
      ],
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "List parent work items",
        "responses": {
          "200": {
            "description": "Array of work items",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/WorkItem"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/work-items/{id}/relations": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WorkItemId"
        }
      ],
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "List all relations for a work item",
        "responses": {
          "200": {
            "description": "Array of relations",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Relation"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/work-items/{work_item_id}/pr-status": {
      "get": {
        "tags": [
          "Work Items"
        ],
        "summary": "Live PR and CI status for a task",
        "description": "The current state of the pull request linked to this item and of its checks. Read live from GitHub, so it reflects the branch now rather than the last webhook Lithora happened to receive.",
        "parameters": [
          {
            "name": "work_item_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The work item's id. NOTE the spelling: every other work-item path templates this segment as {id} and shares the WorkItemId component, which declares `name: id` — reusing it here left `{work_item_id}` undeclared, which is not cosmetic. An OpenAPI 3.1 validator rejects the whole document for it, and a generated client emits this method with no argument for the segment. The path itself is what the app serves and must not be 'tidied' to match its neighbours."
          }
        ],
        "responses": {
          "200": {
            "description": "PR and check status.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/sprints": {
      "get": {
        "tags": [
          "Cycles"
        ],
        "summary": "List cycles",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Restrict to one project.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "description": "Optional. The server infers the team from the project when this is absent, which is the wrong team whenever the caller belongs to more than one — pass it explicitly from anything unattended.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The cycles visible to the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Cycles"
        ],
        "summary": "Open a cycle",
        "description": "Dates are ISO-8601 strings and are stored as given; the server does not normalise a timezone onto them. `task_ids` seeds the cycle at creation time and may be empty — work can be moved in afterwards.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "project_id",
                  "name"
                ],
                "properties": {
                  "project_id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200
                  },
                  "start_date": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "end_date": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "capacity": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "exclusiveMinimum": 0,
                    "maximum": 100000
                  },
                  "capacity_unit": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Free text, e.g. points or hours. The server does not convert between units; it records the label."
                  },
                  "task_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "team_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The created cycle.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/sprints/plan": {
      "post": {
        "tags": [
          "Cycles"
        ],
        "summary": "Propose a cycle's contents from the backlog",
        "description": "READ-ONLY. This opens no cycle and moves no work — it returns a suggested selection under the given budget. Act on it with `POST /api/sprints`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "project_id"
                ],
                "properties": {
                  "project_id": {
                    "type": "string"
                  },
                  "capacity_count": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 1
                  },
                  "capacity_hours": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "exclusiveMinimum": 0
                  },
                  "team_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A proposed selection. Nothing has been changed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/sprints/velocity": {
      "get": {
        "tags": [
          "Cycles"
        ],
        "summary": "Completed work per cycle, over time",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Restrict to one project.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "description": "Optional. The server infers the team from the project when this is absent, which is the wrong team whenever the caller belongs to more than one — pass it explicitly from anything unattended.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Velocity per closed cycle.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/sprints/{cycle_id}": {
      "get": {
        "tags": [
          "Cycles"
        ],
        "summary": "Fetch one cycle",
        "parameters": [
          {
            "name": "cycle_id",
            "in": "path",
            "required": true,
            "description": "The cycle's id. The API calls the object a cycle; the URL calls it a sprint.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "description": "Optional. The server infers the team from the project when this is absent, which is the wrong team whenever the caller belongs to more than one — pass it explicitly from anything unattended.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The cycle.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "Cycles"
        ],
        "summary": "Amend a cycle",
        "description": "An ABSENT field means \"leave it alone\", so there is no value of `capacity` that removes one — send `clear_capacity: true` instead. A client that fills every unsent field in as null blanks the cycle's dates the first time somebody renames it.",
        "parameters": [
          {
            "name": "cycle_id",
            "in": "path",
            "required": true,
            "description": "The cycle's id. The API calls the object a cycle; the URL calls it a sprint.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 1,
                    "maxLength": 200
                  },
                  "start_date": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "end_date": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "capacity": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "exclusiveMinimum": 0,
                    "maximum": 100000
                  },
                  "capacity_unit": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "clear_capacity": {
                    "type": "boolean",
                    "default": false
                  },
                  "team_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The amended cycle.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/sprints/{cycle_id}/close": {
      "post": {
        "tags": [
          "Cycles"
        ],
        "summary": "Close a cycle",
        "description": "Without `target_cycle_id`, unfinished work STAYS in the closed cycle. It is not discarded and it is not returned to the backlog — so omitting the field is a decision, not a safe default.",
        "parameters": [
          {
            "name": "cycle_id",
            "in": "path",
            "required": true,
            "description": "The cycle's id. The API calls the object a cycle; the URL calls it a sprint.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": [
                  "object",
                  "null"
                ],
                "properties": {
                  "target_cycle_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Cycle unfinished work rolls into."
                  },
                  "team_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The closed cycle and what moved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/sprints/{cycle_id}/summary": {
      "get": {
        "tags": [
          "Cycles"
        ],
        "summary": "Burndown and completion figures for one cycle",
        "parameters": [
          {
            "name": "cycle_id",
            "in": "path",
            "required": true,
            "description": "The cycle's id. The API calls the object a cycle; the URL calls it a sprint.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "description": "Optional. The server infers the team from the project when this is absent, which is the wrong team whenever the caller belongs to more than one — pass it explicitly from anything unattended.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Completion counts and the burndown series.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "List webhooks",
        "description": "Secrets are masked on every read.",
        "responses": {
          "200": {
            "description": "The webhooks the caller can see.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Register a webhook",
        "description": "The signing secret is returned by this call and by `POST /api/webhooks/{webhook_id}/regenerate-secret`, and by NOTHING else — every read masks it. Capture it from this response. `events` must name events from `GET /api/webhooks/events/list`; an unknown name is rejected rather than stored as a subscription that could never fire.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "url",
                  "events"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 100
                  },
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "minLength": 1,
                    "maxLength": 2083
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 500
                  },
                  "secret": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Supply your own only if you have somewhere to keep it that is at least as safe as where the server would have kept the generated one."
                  },
                  "active": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The webhook, including its secret — once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/webhooks/events/list": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "The event names a webhook may subscribe to",
        "responses": {
          "200": {
            "description": "Subscribable event names.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/webhooks/{webhook_id}": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Fetch one webhook",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The webhook, secret masked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Amend a webhook",
        "description": "`events` REPLACES the subscription list rather than adding to it. Sending one event to a webhook subscribed to five leaves it subscribed to one.",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 1,
                    "maxLength": 100
                  },
                  "url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uri",
                    "maxLength": 2083
                  },
                  "events": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "string"
                    }
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "secret": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "active": {
                    "type": [
                      "boolean",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The amended webhook.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Delete a webhook",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deletion result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/webhooks/{webhook_id}/logs": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Recent delivery attempts",
        "description": "Where a webhook that \"does not work\" is diagnosed: each row carries the status the RECEIVER returned, which is the answer nearly every time.",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "How many attempts to return, newest first.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Delivery attempts, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Discard the delivery log",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "How many rows were removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/webhooks/{webhook_id}/stats": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Delivery success and failure counts",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Aggregate delivery health.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/webhooks/{webhook_id}/regenerate-secret": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Mint a new signing secret",
        "description": "The previous secret stops being accepted IMMEDIATELY — there is no overlap window — so the receiver has to be updated in the same breath or its next delivery fails signature verification. Returned once.",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The new secret — once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/webhooks/{webhook_id}/test": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Send a synthetic delivery",
        "description": "A REAL, signed HTTP request to the configured URL. Not a dry run: point it at a receiver that expects it.",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "description": "The webhook's id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "event_type",
                  "data"
                ],
                "properties": {
                  "event_type": {
                    "type": "string"
                  },
                  "data": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What the receiver answered.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/export/data": {
      "post": {
        "tags": [
          "Bulk Data"
        ],
        "summary": "Export records",
        "description": "Validate `include_fields` against `GET /api/export/fields/{entity_type}` first: a field name the server does not recognise is how an export quietly comes back missing a column.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "format",
                  "entity_type"
                ],
                "properties": {
                  "format": {
                    "type": "string",
                    "description": "csv, json or xlsx."
                  },
                  "entity_type": {
                    "type": "string"
                  },
                  "include_fields": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "string"
                    }
                  },
                  "filters": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "additionalProperties": true
                  },
                  "date_range": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "additionalProperties": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The export, or a handle to it.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/export/fields/{entity_type}": {
      "get": {
        "tags": [
          "Bulk Data"
        ],
        "summary": "Exportable field names for an entity",
        "parameters": [
          {
            "name": "entity_type",
            "in": "path",
            "required": true,
            "description": "Entity type, e.g. `task` or `project`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Field names this entity can be exported with.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/export/import/{entity_type}": {
      "post": {
        "tags": [
          "Bulk Data"
        ],
        "summary": "Import records of one entity type",
        "description": "Note the asymmetry, which is the server's shape and not a mistake: bulk WORK import is `POST /api/import/tasks`, and every other entity imports through this route. There is no dry-run mode — rehearse with a small file against a scratch workspace.",
        "parameters": [
          {
            "name": "entity_type",
            "in": "path",
            "required": true,
            "description": "Entity type, e.g. `task` or `project`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "How many rows were imported, and what was rejected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/import/tasks": {
      "post": {
        "tags": [
          "Bulk Data"
        ],
        "summary": "Bulk-create work from a file",
        "description": "`project_id` is required: the server places every imported row in a project and will not guess one. An EMPTY file is accepted as zero rows and reports success, which is indistinguishable from a working import that found nothing — check the file before sending it.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "project_id"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "project_id": {
                    "type": "string"
                  },
                  "source": {
                    "type": "string",
                    "default": "csv"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "How many rows were imported, and what was rejected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/custom-fields": {
      "get": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "List custom field definitions",
        "description": "Every definition you can see, sorted by `order`: the `public` ones belonging to teams you are on, plus your own `private` ones. Returns a bare array. There is no team_id filter — filter client-side on the `team_id` each row carries. Not paginated; hard-capped at 5,000 definitions.",
        "parameters": [
          {
            "name": "entity_type",
            "in": "query",
            "required": false,
            "description": "Restrict to definitions declared for tasks or for projects.",
            "schema": {
              "$ref": "#/components/schemas/CustomFieldEntityType"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Array of definitions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CustomField"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "Create a custom field definition",
        "description": "Creates a definition inside `team_id`, which you must be a member of. `field_type` and `entity_type` are fixed at creation — nothing can change them afterwards.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomFieldCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created definition",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomFieldCreated"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/custom-fields/search": {
      "get": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "Find tasks or projects by a custom field value",
        "description": "Case-insensitive SUBSTRING match, not equality, and the needle is escaped and truncated to 200 characters before it reaches the database — you cannot send a regular expression. The field must have `is_searchable` set (400 otherwise). Matches are then filtered to the entities you can reach, so `count` can be lower than the number of stored values that matched; the underlying match is capped at 5,000.",
        "parameters": [
          {
            "name": "field_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "value",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Substring to look for. For a `multiselect` field this matches the serialised list, so a short needle can match more than you expect."
          },
          {
            "name": "entity_type",
            "in": "query",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/CustomFieldEntityType"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The definition plus the entities you can see",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomFieldSearchResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/custom-fields/values": {
      "post": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "Set a custom field value on a task or project",
        "description": "Upsert. Three gates apply in order and all three can answer 403: you must reach the target entity, you must be able to read the definition, and the definition's team must match the entity's team. The last one is the surprising one — belonging to both teams is not enough.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomFieldValueWrite"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Value stored",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/custom-fields/values/{entity_id}": {
      "get": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "All custom field values on one entity",
        "description": "Returns a bare array, each row carrying its `field_definition` where the definition still exists. The path does not say whether `entity_id` is a task or a project — the server tries both and answers 404 when neither is reachable, so an id you cannot see is indistinguishable from one that does not exist. Capped at 5,000 values.",
        "parameters": [
          {
            "name": "entity_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "A task_id or a project_id."
          }
        ],
        "responses": {
          "200": {
            "description": "Array of values",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CustomFieldValue"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/custom-fields/values/{entity_id}/{field_id}": {
      "delete": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "Clear one custom field value",
        "description": "Removes the stored value, leaving the definition alone. 404 when the pair holds no value, which is also what you get for an entity you cannot reach.",
        "parameters": [
          {
            "name": "entity_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "field_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Value deleted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/custom-fields/{field_id}": {
      "get": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "Read one custom field definition",
        "parameters": [
          {
            "name": "field_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The definition",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomField"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "Update a custom field definition",
        "description": "Creator or an admin/owner of the definition's team. Returns a bare acknowledgement, not the updated definition — re-read it if you need the new state.",
        "parameters": [
          {
            "name": "field_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomFieldUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Definition updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Custom Fields"
        ],
        "summary": "Delete a custom field definition and every value stored against it",
        "description": "DESTRUCTIVE AND CASCADING: this removes the definition AND every value any task or project holds for it, in every project of the team. There is no soft delete and no undo. Creator or a team admin/owner only.",
        "parameters": [
          {
            "name": "field_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Definition and values deleted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/saved-views": {
      "get": {
        "tags": [
          "Saved Views"
        ],
        "summary": "List saved views",
        "description": "Your own views plus the shared views of teams you are on, ordered defaults-first then by name. NOTE THE ENVELOPE: this one returns `{\"views\": [...]}`, unlike the task and work-item lists which return bare arrays. Capped, and not paginated.",
        "parameters": [
          {
            "name": "entity_type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "task",
                "project"
              ]
            }
          },
          {
            "name": "team_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Narrow the shared half to one team you belong to (403 otherwise). Your own private views are always included regardless of this filter."
          }
        ],
        "responses": {
          "200": {
            "description": "Views you can see",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "views"
                  ],
                  "properties": {
                    "views": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SavedView"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "tags": [
          "Saved Views"
        ],
        "summary": "Create a saved view",
        "description": "Returns an `ActionReceipt`; the created view is in `data`, so read the id from `data.view_id`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SavedViewCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created view",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActionReceipt"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/saved-views/{view_id}": {
      "get": {
        "tags": [
          "Saved Views"
        ],
        "summary": "Read one saved view",
        "description": "Yours, or one shared inside a team you are on. 404 — not 403 — for anything else, so view ids cannot be enumerated across tenants.",
        "parameters": [
          {
            "name": "view_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The view",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SavedView"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "Saved Views"
        ],
        "summary": "Update a saved view",
        "description": "Owner only. A teammate who can read a shared view gets 404 here, not 403 — the ownership check and the existence check are the same query.",
        "parameters": [
          {
            "name": "view_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SavedViewUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated view",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SavedView"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "Saved Views"
        ],
        "summary": "Delete a saved view",
        "description": "Owner only, and permanent. Deleting a shared view removes it from every teammate's switcher.",
        "parameters": [
          {
            "name": "view_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "View deleted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActionReceipt"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/saved-views/{view_id}/set-default": {
      "post": {
        "tags": [
          "Saved Views"
        ],
        "summary": "Make a view the default for its entity type",
        "description": "Owner only. Clears `is_default` on your other views of the same `entity_type` first, so the flag is single-valued per user per entity type. The default is PER USER — it does not become the team's default, even for a shared view.",
        "parameters": [
          {
            "name": "view_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Default set",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActionReceipt"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/activity": {
      "get": {
        "tags": [
          "Activity"
        ],
        "summary": "Activity timeline",
        "description": "Newest first, as a bare array. Scoped to your own actions plus everything in projects you can see; passing a `project_id` you cannot see is 403. This is a FEED, not an audit log — rows are best-effort, carry no before/after state, and are not guaranteed to exist for every mutation.",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Restrict to one project you can see."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            },
            "description": "Clamped to 200; a larger value is silently reduced rather than rejected."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Offset paging over a feed that grows at the head, so a new row arriving between pages shifts everything down one and you will see a duplicate."
          }
        ],
        "responses": {
          "200": {
            "description": "Array of activity rows",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ActivityEntry"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/notes/{note_id}": {
      "delete": {
        "tags": [
          "Notes"
        ],
        "summary": "Delete a note",
        "parameters": [
          {
            "in": "path",
            "name": "note_id",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The note's `note_id`."
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResponse"
                }
              }
            },
            "description": "Confirmation message."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "Only the note's creator may delete it; team membership is not enough."
      },
      "get": {
        "tags": [
          "Notes"
        ],
        "summary": "Fetch one note",
        "parameters": [
          {
            "in": "path",
            "name": "note_id",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The note's `note_id`."
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NoteRow"
                }
              }
            },
            "description": "The note."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        }
      },
      "put": {
        "tags": [
          "Notes"
        ],
        "summary": "Replace a note",
        "parameters": [
          {
            "in": "path",
            "name": "note_id",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The note's `note_id`."
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Note"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResponse"
                }
              }
            },
            "description": "Confirmation message."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "A full replacement, not a patch — every field of the note is taken from the body. Sending a different `project_id` MOVES the note, and the caller must be able to write to the destination project as well as to the current one."
      }
    },
    "/api/task-comments/{comment_id}": {
      "delete": {
        "tags": [
          "Task Comments"
        ],
        "summary": "Delete a comment",
        "parameters": [
          {
            "in": "path",
            "name": "comment_id",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The comment's `comment_id`."
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommentDeletedResponse"
                }
              }
            },
            "description": "Confirmation message."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "Only the comment's author may delete it. The delete is soft: the row is marked deleted and stops appearing in the thread and the count."
      },
      "put": {
        "tags": [
          "Task Comments"
        ],
        "summary": "Edit a comment",
        "parameters": [
          {
            "in": "path",
            "name": "comment_id",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The comment's `comment_id`."
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CommentUpdate"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommentUpdatedResponse"
                }
              }
            },
            "description": "The edited content and its new timestamp."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "Only the comment's author may edit it."
      }
    },
    "/api/notes": {
      "get": {
        "tags": [
          "Notes"
        ],
        "summary": "List notes",
        "parameters": [
          {
            "in": "query",
            "name": "project_id",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "description": "Restrict to one project."
          },
          {
            "description": "Page size. Defaults to 50, clamped to 200.",
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "integer"
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          {
            "description": "Rows to skip.",
            "in": "query",
            "name": "offset",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "integer"
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          {
            "description": "1-indexed alternative to `offset`. Ignored when `offset` is sent.",
            "in": "query",
            "name": "page",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "integer"
                },
                {
                  "type": "null"
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "$ref": "#/components/schemas/NoteRow"
                  },
                  "type": "array"
                }
              }
            },
            "description": "Array of notes, newest first."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "Notes from the projects the caller can reach. Without `project_id` that is every project of every team the caller belongs to, plus any note the caller created themselves; with it, the one project — and a project the caller cannot reach is a 403, not an empty page."
      },
      "post": {
        "tags": [
          "Notes"
        ],
        "summary": "Create a note",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Note"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NoteRow"
                }
              }
            },
            "description": "The created note."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "`project_id` decides who can see the note: the caller must be a member, admin or owner of that project's team. The created note is echoed back."
      }
    },
    "/api/task-comments/{task_id}": {
      "get": {
        "tags": [
          "Task Comments"
        ],
        "summary": "List a task's comment thread",
        "parameters": [
          {
            "in": "path",
            "name": "task_id",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The task whose thread to read."
          },
          {
            "in": "query",
            "name": "subtask_id",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "description": "Read one subtask's thread instead of the task's own top-level thread."
          },
          {
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "default": 100,
              "maximum": 200,
              "minimum": 1,
              "type": "integer"
            },
            "description": "Page size. Defaults to 100, maximum 200."
          },
          {
            "description": "Return only comments created before this `created_at` value. Use the `next_before` from the previous page.",
            "in": "query",
            "name": "before",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommentThreadResponse"
                }
              }
            },
            "description": "One page of the thread."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "One page of the thread, oldest first, with replies nested under their parent in a `replies` array. The page is taken from the NEWEST end: pass the returned `next_before` back as `before` to walk backwards through the history. A reply whose parent falls outside the page renders as top-level."
      }
    },
    "/api/task-comments/{task_id}/count": {
      "get": {
        "tags": [
          "Task Comments"
        ],
        "summary": "Count a task's comments",
        "parameters": [
          {
            "in": "path",
            "name": "task_id",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The task to count for."
          },
          {
            "in": "query",
            "name": "subtask_id",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "description": "Count one subtask's comments instead of the whole task's."
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommentCountResponse"
                }
              }
            },
            "description": "The comment count."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "Deleted comments are excluded. Intended for badge counts, so it does not read the bodies."
      }
    },
    "/api/task-comments": {
      "post": {
        "tags": [
          "Task Comments"
        ],
        "summary": "Post a comment",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CommentCreate"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommentCreatedResponse"
                }
              }
            },
            "description": "The created comment."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "description": "Send `parent_id` to reply. Replies nest two deep: a reply may itself be replied to, and only a third level is refused — with a 400, not by silently flattening it."
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Send `Authorization: Bearer <credential>`.\n\nTwo credential types are accepted and resolve to the same user:\n* **Personal access token** — `lth_pat_<urlsafe-random>`. Minted at Settings -> API Tokens (or `POST /api/auth/tokens`), scoped, expiring, revocable. Preferred for CI, scripts, the CLI and the SDKs.\n* **JWT (HS256)** — returned by `POST /api/auth/login`. Short-lived and tied to an interactive session.\n\nA `401` means the credential is missing, invalid, expired or revoked. A `403` with `\"missing the required scope\"` means the PAT authenticated but was not granted the scope the route requires."
      }
    },
    "parameters": {
      "TeamId": {
        "name": "team_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        }
      },
      "ProjectId": {
        "name": "project_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        }
      },
      "TaskId": {
        "name": "task_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        }
      },
      "WorkItemId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid token",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "BadRequest": {
        "description": "Invalid request (e.g. a required scope like team_id/project_id is missing)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated but not authorized for this resource",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "Request validation failed. The `detail.errors` array names each rejected field.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "CycleTimeSummary": {
        "type": "object",
        "description": "Aggregate cycle-time stats for a scope. Only 'count' is present when there are no measured items.",
        "properties": {
          "count": {
            "type": "integer"
          },
          "median_seconds": {
            "type": [
              "number",
              "null"
            ]
          },
          "median_days": {
            "type": [
              "number",
              "null"
            ]
          },
          "p90_seconds": {
            "type": [
              "number",
              "null"
            ]
          },
          "p90_days": {
            "type": [
              "number",
              "null"
            ]
          },
          "avg_seconds": {
            "type": [
              "number",
              "null"
            ]
          },
          "avg_days": {
            "type": [
              "number",
              "null"
            ]
          },
          "min_days": {
            "type": [
              "number",
              "null"
            ]
          },
          "max_days": {
            "type": [
              "number",
              "null"
            ]
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "Structured error envelope emitted by backend/middleware/error_handler.py for every non-2xx response. The `X-Correlation-ID` response header carries the same value as `correlation_id`; quote it in support requests.\n\n`detail` is the human-readable string for most errors, and an object of the form `{\"errors\": [{\"field\", \"message\", \"type\"}]}` on a 422. On a 5xx, `message` and `detail` are deliberately generic — the real cause is in the server logs under the correlation id.",
        "required": [
          "success",
          "error_code",
          "message",
          "correlation_id",
          "timestamp"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "verified": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "failure"
            ]
          },
          "error_code": {
            "type": "string",
            "enum": [
              "INVALID_REQUEST",
              "AUTHENTICATION_REQUIRED",
              "PERMISSION_DENIED",
              "RESOURCE_NOT_FOUND",
              "RESOURCE_CONFLICT",
              "RATE_LIMIT_EXCEEDED",
              "VALIDATION_ERROR",
              "INTERNAL_ERROR"
            ]
          },
          "message": {
            "type": "string"
          },
          "detail": {
            "description": "Error string, or {\"errors\": [...]} on a 422.",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "object",
                "additionalProperties": true
              }
            ]
          },
          "correlation_id": {
            "type": "string"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "DeleteResult": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "example": "success"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "LoginRequest": {
        "type": "object",
        "required": [
          "email",
          "password"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "password": {
            "type": "string"
          },
          "device_auth": {
            "type": "boolean",
            "default": false,
            "description": "Set true only by trusted first-party companion apps to skip the new-device OTP."
          }
        }
      },
      "AuthSuccess": {
        "type": "object",
        "description": "Successful authentication. Contains a JWT to use as a Bearer token.",
        "required": [
          "token",
          "user"
        ],
        "properties": {
          "token": {
            "type": "string",
            "description": "JWT (HS256)."
          },
          "requires_2fa": {
            "type": "boolean",
            "default": false
          },
          "requires_login_otp": {
            "type": "boolean",
            "default": false
          },
          "user": {
            "$ref": "#/components/schemas/User"
          }
        }
      },
      "AuthChallenge": {
        "type": "object",
        "description": "Returned by /api/auth/login when authentication is incomplete. NO token is present. Exactly one of requires_2fa / requires_login_otp is true.",
        "properties": {
          "requires_2fa": {
            "type": "boolean",
            "description": "True when the account has TOTP 2FA enabled."
          },
          "requires_login_otp": {
            "type": "boolean",
            "description": "True when logging in from an untrusted device; an OTP is emailed."
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "User": {
        "type": "object",
        "properties": {
          "user_id": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string"
          },
          "role": {
            "type": "string",
            "enum": [
              "admin",
              "manager",
              "member"
            ]
          },
          "team_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Present on login/me responses."
          }
        },
        "required": [
          "user_id",
          "email",
          "name",
          "role"
        ]
      },
      "TeamCreate": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          },
          "member_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Optional initial members (user ids)."
          }
        }
      },
      "Team": {
        "type": "object",
        "properties": {
          "team_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "member_ids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "created_at": {
            "type": "string"
          }
        }
      },
      "ProjectCreate": {
        "type": "object",
        "required": [
          "name",
          "team_id"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "team_id": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived",
              "completed"
            ],
            "default": "active"
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO date/datetime string."
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO date/datetime string."
          }
        }
      },
      "ProjectUpdate": {
        "type": "object",
        "description": "All fields optional; used for PUT and PATCH.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "active",
              "archived",
              "completed",
              null
            ]
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ]
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "Project": {
        "type": "object",
        "properties": {
          "project_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "team_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived",
              "completed"
            ]
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ]
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string"
          }
        }
      },
      "TaskCreate": {
        "type": "object",
        "required": [
          "title",
          "project_id"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "project_id": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000
          },
          "assigned_to": {
            "type": [
              "string",
              "null"
            ],
            "description": "User id; must be a member of the project's team."
          },
          "status": {
            "type": "string",
            "enum": [
              "todo",
              "in_progress",
              "in_review",
              "done"
            ],
            "default": "todo"
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ],
            "default": "medium"
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO date/datetime string."
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 20,
            "default": []
          },
          "estimated_hours": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 1000
          },
          "parent_task_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Links this task as a subtask of another."
          }
        }
      },
      "Task": {
        "type": "object",
        "properties": {
          "task_id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "project_id": {
            "type": "string"
          },
          "assigned_to": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "todo",
              "in_progress",
              "in_review",
              "done"
            ]
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ]
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ]
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "estimated_hours": {
            "type": [
              "number",
              "null"
            ]
          },
          "parent_task_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "github_repo_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "version": {
            "type": "integer",
            "description": "Optimistic concurrency version."
          },
          "created_at": {
            "type": "string"
          }
        }
      },
      "WorkItemType": {
        "type": "string",
        "description": "Entity type a work item wraps.",
        "enum": [
          "task",
          "project",
          "whiteboard",
          "file",
          "note",
          "event",
          "goal",
          "doc"
        ]
      },
      "WorkItemCreate": {
        "type": "object",
        "required": [
          "type",
          "entity_id",
          "team_id"
        ],
        "properties": {
          "type": {
            "$ref": "#/components/schemas/WorkItemType"
          },
          "entity_id": {
            "type": "string",
            "description": "ID of the underlying entity (task_id, project_id, etc.)."
          },
          "team_id": {
            "type": "string"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent work item id (for hierarchy)."
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "WorkItemUpdate": {
        "type": "object",
        "properties": {
          "parent_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "metadata": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          }
        }
      },
      "WorkItem": {
        "type": "object",
        "properties": {
          "work_item_id": {
            "type": "string"
          },
          "type": {
            "$ref": "#/components/schemas/WorkItemType"
          },
          "entity_id": {
            "type": "string"
          },
          "team_id": {
            "type": "string"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true
          },
          "created_by": {
            "type": "string"
          },
          "created_at": {
            "type": "string"
          },
          "updated_at": {
            "type": "string"
          }
        }
      },
      "RelationType": {
        "type": "string",
        "description": "Type of edge between two work items.",
        "enum": [
          "parent_child",
          "blocking",
          "blocked_by",
          "related_to",
          "attached_to",
          "belongs_to",
          "caused_by",
          "recurrence_of",
          "blocks"
        ]
      },
      "RelationCreate": {
        "type": "object",
        "required": [
          "source_id",
          "target_id",
          "relation_type"
        ],
        "properties": {
          "source_id": {
            "type": "string",
            "description": "Source work item id."
          },
          "target_id": {
            "type": "string",
            "description": "Target work item id."
          },
          "relation_type": {
            "$ref": "#/components/schemas/RelationType"
          }
        }
      },
      "Relation": {
        "type": "object",
        "properties": {
          "relation_id": {
            "type": "string"
          },
          "source_id": {
            "type": "string"
          },
          "target_id": {
            "type": "string"
          },
          "relation_type": {
            "$ref": "#/components/schemas/RelationType"
          },
          "created_at": {
            "type": "string"
          }
        }
      },
      "ChatRequest": {
        "type": "object",
        "required": [
          "session_id",
          "message"
        ],
        "properties": {
          "session_id": {
            "type": "string"
          },
          "message": {
            "type": "string",
            "minLength": 1,
            "maxLength": 10000
          }
        }
      },
      "ChatResponse": {
        "type": "object",
        "required": [
          "response"
        ],
        "properties": {
          "response": {
            "type": "string"
          },
          "action_plan": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Proposed actions; present when the agent wants to mutate the work graph."
          },
          "requires_confirmation": {
            "type": "boolean",
            "default": false,
            "description": "When true, the plan must be confirmed via /api/ai-workspace/actions/confirm before it executes."
          }
        }
      },
      "TokenScope": {
        "type": "string",
        "description": "A grantable PAT scope (backend/utils/pat.py VALID_SCOPES). `admin:*` is a wildcard granting every scope.",
        "enum": [
          "admin:*",
          "ai:chat",
          "ai:confirm",
          "automations:read",
          "automations:write",
          "graph:read",
          "projects:read",
          "projects:write",
          "tasks:read",
          "tasks:write",
          "teams:read"
        ]
      },
      "TokenCreate": {
        "type": "object",
        "required": [
          "name",
          "scopes"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Human label, e.g. 'ci-release-bot'."
          },
          "scopes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TokenScope"
            },
            "minItems": 1,
            "description": "At least one scope is required; an empty list is rejected."
          },
          "expires_in_days": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 3650,
            "default": 90,
            "description": "0 or null means the token never expires (discouraged)."
          }
        }
      },
      "Token": {
        "type": "object",
        "description": "A PAT as returned by list/create. Never contains the secret except in the create response.",
        "properties": {
          "token_id": {
            "type": "string",
            "example": "pat_9f2c1b7a4d3e6510"
          },
          "name": {
            "type": "string"
          },
          "scopes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TokenScope"
            }
          },
          "prefix": {
            "type": "string",
            "description": "Non-secret display prefix.",
            "example": "lth_pat_Ab3dEf9h…"
          },
          "token_prefix": {
            "type": "string",
            "description": "Backward-compatible alias of `prefix`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "revoked": {
            "type": "boolean"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "expired",
              "revoked"
            ]
          }
        }
      },
      "TokenCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Token"
          },
          {
            "type": "object",
            "required": [
              "token"
            ],
            "properties": {
              "token": {
                "type": "string",
                "description": "The secret, returned EXACTLY ONCE. It is stored server-side only as a SHA-256 hash and cannot be retrieved again.",
                "example": "lth_pat_Ab3dEf9hJk2LmN4pQr6StU8vWx0YzA1bCd3EfG5hIj7"
              }
            }
          }
        ]
      },
      "AffectedEntity": {
        "type": "object",
        "description": "Cache-invalidation hint: one entity touched by the mutation.",
        "properties": {
          "entity_type": {
            "type": "string",
            "example": "project"
          },
          "entity_id": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "enum": [
              "created",
              "updated",
              "deleted"
            ]
          }
        }
      },
      "ActionReceipt": {
        "type": "object",
        "description": "Mutation envelope used by the team and project write endpoints. `verified` is true only once the database write is confirmed — never render success on a receipt whose `verified` is false. The created or updated entity is in `data`, so read ids from `data.project_id` / `data.team_id`, not from the top level.",
        "required": [
          "action_id",
          "status",
          "verified",
          "message"
        ],
        "properties": {
          "action_id": {
            "type": "string",
            "format": "uuid",
            "description": "Audit-trail handle for this mutation."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "applied",
              "verified",
              "failed",
              "rolled_back"
            ]
          },
          "verified": {
            "type": "boolean"
          },
          "message": {
            "type": "string"
          },
          "affected_entities": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AffectedEntity"
            }
          },
          "data": {
            "description": "The created or updated entity.",
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "rollback_available": {
            "type": "boolean"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "MessageResult": {
        "type": "object",
        "description": "Bare acknowledgement — the mutated entity is NOT returned. Re-read it if you need the new state.",
        "required": [
          "message"
        ],
        "properties": {
          "message": {
            "type": "string"
          }
        }
      },
      "SoftDeleteResult": {
        "type": "object",
        "description": "Soft delete. The record moves to Trash and is restorable for 30 days; it disappears from every list and lookup immediately.",
        "properties": {
          "message": {
            "type": "string",
            "example": "Task moved to Trash"
          },
          "success": {
            "type": "boolean"
          },
          "verified": {
            "type": "boolean"
          },
          "soft_deleted": {
            "type": "boolean"
          },
          "deleted_at": {
            "type": "string",
            "format": "date-time"
          },
          "restore_url": {
            "type": "string",
            "example": "/api/trash/task/8f1c0c2e/restore"
          },
          "retention_days": {
            "type": "integer",
            "description": "Days the record stays restorable."
          }
        }
      },
      "CustomFieldType": {
        "type": "string",
        "description": "The storage and validation rule for a definition's values. `dropdown` and `multiselect` require a non-empty `options` list at create time; the value write path rejects anything not in that list.",
        "enum": [
          "text",
          "number",
          "date",
          "dropdown",
          "checkbox",
          "multiselect"
        ]
      },
      "CustomFieldEntityType": {
        "type": "string",
        "description": "What a definition may be bound to. A definition declared for `task` can never hold a value on a project, and vice versa.",
        "enum": [
          "task",
          "project"
        ]
      },
      "CustomFieldCreate": {
        "type": "object",
        "required": [
          "name",
          "team_id",
          "field_type",
          "entity_type"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "team_id": {
            "type": "string",
            "minLength": 1,
            "description": "The team the definition belongs to. You must be a member; a definition cannot be planted into a team you are not on."
          },
          "field_type": {
            "$ref": "#/components/schemas/CustomFieldType"
          },
          "entity_type": {
            "$ref": "#/components/schemas/CustomFieldEntityType"
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          },
          "options": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Allowed values for `dropdown` and `multiselect`. Required and non-empty for those two types (400 otherwise); ignored for the rest."
          },
          "default_value": {
            "description": "Stored on the definition only. It is NOT applied to existing entities and the value endpoints do not fall back to it — read it and send it yourself if you want it applied."
          },
          "is_required": {
            "type": "boolean",
            "default": false,
            "description": "Advisory. Nothing in the API refuses a task or project that is missing a required custom field; the flag is for the clients that choose to enforce it."
          },
          "is_searchable": {
            "type": "boolean",
            "default": true,
            "description": "When false, `GET /api/custom-fields/search` refuses this field with 400."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "public",
              "private"
            ],
            "default": "public",
            "description": "`public` means visible to the members of `team_id`, not to the world. `private` means visible to its creator alone."
          },
          "order": {
            "type": "integer",
            "minimum": 0,
            "default": 0,
            "description": "Sort key for the list endpoint."
          }
        }
      },
      "CustomFieldUpdate": {
        "type": "object",
        "description": "Every field is optional; only the keys you send are written. Note what is ABSENT: `field_type`, `entity_type` and `team_id` cannot be changed, because values already stored against the definition were validated under the old ones.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "description": {
            "type": "string",
            "maxLength": 500
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Replaces the list wholesale. Values already stored against a removed option are NOT revalidated or cleaned up — they keep the now-invalid value until rewritten."
          },
          "default_value": {},
          "is_required": {
            "type": "boolean"
          },
          "is_searchable": {
            "type": "boolean"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "public",
              "private"
            ]
          },
          "order": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "CustomField": {
        "type": "object",
        "description": "A custom field definition as stored. Older definitions predate `visibility`, `is_searchable` and `order`, so those keys can be absent rather than null on rows written by earlier builds — read defensively.",
        "properties": {
          "field_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "team_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "field_type": {
            "$ref": "#/components/schemas/CustomFieldType"
          },
          "entity_type": {
            "$ref": "#/components/schemas/CustomFieldEntityType"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "options": {
            "type": "array",
            "items": {}
          },
          "default_value": {},
          "is_required": {
            "type": "boolean"
          },
          "is_searchable": {
            "type": "boolean"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "public",
              "private"
            ]
          },
          "order": {
            "type": "integer"
          },
          "created_by": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CustomFieldCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CustomField"
          },
          {
            "type": "object",
            "required": [
              "field_id",
              "message"
            ],
            "properties": {
              "message": {
                "type": "string"
              }
            }
          }
        ],
        "description": "The whole created definition, plus a `message`. Read the id from `field_id`."
      },
      "CustomFieldValueWrite": {
        "type": "object",
        "required": [
          "field_id",
          "entity_id",
          "value"
        ],
        "description": "An upsert: one (field_id, entity_id) pair holds at most one value, and writing again replaces it. There is no separate create/update.",
        "properties": {
          "field_id": {
            "type": "string"
          },
          "entity_id": {
            "type": "string",
            "description": "The task_id or project_id, matching the definition's `entity_type`. It must live in the SAME team as the definition — binding one team's definition to another team's task is refused with 403 even when you belong to both."
          },
          "value": {
            "description": "Validated against `field_type`: `number` is coerced with a 400 on failure, `checkbox` must be a JSON boolean, `dropdown` must be one of `options`, `multiselect` must be an array of `options`. `text` and `date` are stored as sent — a malformed date is accepted."
          }
        }
      },
      "CustomFieldValue": {
        "type": "object",
        "description": "One stored value. `field_definition` is attached only when the definition still exists; a value whose definition was deleted is unreachable through the delete-by-field path but can still be returned by an older read, so treat its absence as normal rather than as an error.",
        "properties": {
          "field_id": {
            "type": "string"
          },
          "entity_id": {
            "type": "string"
          },
          "value": {},
          "updated_by": {
            "type": "string"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "field_definition": {
            "description": "The definition this value belongs to, embedded to save a round trip.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/CustomField"
              }
            ]
          }
        }
      },
      "CustomFieldSearchResult": {
        "type": "object",
        "required": [
          "field",
          "entities",
          "count"
        ],
        "properties": {
          "field": {
            "$ref": "#/components/schemas/CustomField"
          },
          "entities": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "The matching tasks or projects, already filtered to the ones you can reach. A value can point at an entity in another tenant; those are dropped rather than returned."
          },
          "count": {
            "type": "integer",
            "description": "Length of `entities` — the number you can SEE, not the number that matched."
          }
        }
      },
      "SavedViewFilter": {
        "type": "object",
        "required": [
          "field",
          "operator"
        ],
        "description": "One filter clause. The API stores and returns these verbatim; it does not evaluate them — running the filter is the client's job, which is why an unknown `field` is accepted here and simply matches nothing later.",
        "properties": {
          "field": {
            "type": "string"
          },
          "operator": {
            "type": "string",
            "enum": [
              "eq",
              "ne",
              "gt",
              "lt",
              "gte",
              "lte",
              "in",
              "contains"
            ]
          },
          "value": {
            "description": "Used by every operator except `in`."
          },
          "values": {
            "type": "array",
            "items": {},
            "description": "Used by `in`."
          }
        }
      },
      "SavedViewSort": {
        "type": "object",
        "required": [
          "field"
        ],
        "properties": {
          "field": {
            "type": "string"
          },
          "direction": {
            "type": "string",
            "enum": [
              "asc",
              "desc"
            ],
            "default": "asc"
          }
        }
      },
      "SavedViewCreate": {
        "type": "object",
        "required": [
          "name",
          "entity_type"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "entity_type": {
            "type": "string",
            "enum": [
              "task",
              "project"
            ]
          },
          "team_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Required when `is_shared` is true (400 otherwise), and you must be a member of it. A platform operator is deliberately NOT waved through here: a shared view is planted in the team's own view switcher and only its owner can remove it."
          },
          "is_shared": {
            "type": "boolean",
            "default": false,
            "description": "Shared WITHIN `team_id`, never globally."
          },
          "filters": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SavedViewFilter"
            }
          },
          "sort": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/SavedViewSort"
              }
            ],
            "type": [
              "object",
              "null"
            ]
          },
          "group_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "columns": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          },
          "is_default": {
            "type": "boolean",
            "default": false,
            "description": "Setting this clears the default flag on your other views of the same `entity_type` — one default per entity type per user."
          }
        }
      },
      "SavedViewUpdate": {
        "type": "object",
        "description": "Owner-only, and every key optional. `entity_type` and `team_id` are absent on purpose: a view cannot change what it lists or which team it is shared with — create a new one.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "is_shared": {
            "type": "boolean"
          },
          "filters": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SavedViewFilter"
            },
            "description": "Replaces the list wholesale."
          },
          "sort": {
            "$ref": "#/components/schemas/SavedViewSort"
          },
          "group_by": {
            "type": "string"
          },
          "columns": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "is_default": {
            "type": "boolean"
          }
        }
      },
      "SavedView": {
        "type": "object",
        "description": "A saved view as stored. Rows written by earlier builds may omit `columns`, `group_by` and `team_id` entirely rather than carrying null.",
        "properties": {
          "view_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "entity_type": {
            "type": "string",
            "enum": [
              "task",
              "project"
            ]
          },
          "user_id": {
            "type": "string",
            "description": "The owner. Compare it against your own id before offering an edit affordance — a shared view you can read is not one you can write."
          },
          "team_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_shared": {
            "type": "boolean"
          },
          "filters": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SavedViewFilter"
            }
          },
          "sort": {
            "type": [
              "object",
              "null"
            ],
            "oneOf": [
              {
                "$ref": "#/components/schemas/SavedViewSort"
              }
            ]
          },
          "group_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "columns": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          },
          "is_default": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ActivityEntry": {
        "type": "object",
        "description": "One timeline row. There is no entity SNAPSHOT here — `action` plus `entity_type`/`entity_id` is all that is recorded, so rendering what changed means re-reading the entity, and an entity since deleted cannot be rendered at all.",
        "properties": {
          "activity_id": {
            "type": "string",
            "format": "uuid"
          },
          "_id": {
            "type": "string",
            "description": "The stringified Mongo id, kept in the response for historical reasons. Key on `activity_id`."
          },
          "user_id": {
            "type": "string"
          },
          "user_name": {
            "type": "string",
            "description": "Resolved at read time and omitted when the account no longer exists."
          },
          "action": {
            "type": "string",
            "description": "Free-form verb as written by the mutation — `created`, `updated`, `deleted`, `completed` and others. Not a closed enum; do not switch on it exhaustively."
          },
          "entity_type": {
            "type": "string"
          },
          "entity_id": {
            "type": "string"
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AuditLogEntry": {
        "type": "object",
        "description": "One audit entry, as stored. Every field is optional in the schema because the API serialises these with `exclude_unset` — a key that was never written is ABSENT rather than null. The tamper-evidence fields are exposed on purpose: they are what makes the log tamper-EVIDENT rather than merely append-only, and a compliance client that cannot recompute the digest itself has to take our word for it. Note what is NOT here: the chain's SEQUENCE NUMBER is written to a separate integrity collection, not onto the entry, so what you can verify from this document is the `previous_hash` linkage — not a numbering, and therefore not, on its own, the absence of a deletion.",
        "properties": {
          "audit_id": {
            "type": "string",
            "description": "Stable id for this entry. Address `/api/audit/logs/{audit_id}` with it."
          },
          "previous_hash": {
            "type": "string",
            "description": "The `entry_hash` of the preceding entry in the global chain, or `\"genesis\"` for the first one. This is the link, and it is the only chain field an entry carries."
          },
          "entry_hash": {
            "type": "string",
            "description": "Digest over this entry's content, computed under `hash_algo`. The field is named `entry_hash`; there is no `content_hash` on the wire."
          },
          "hash_algo": {
            "type": "string",
            "enum": [
              "sha256",
              "hmac-sha256"
            ],
            "description": "Which construction produced `entry_hash`: `hmac-sha256` where a chain key is configured, `sha256` for entries written before one was. A verifier MUST recompute using the value ON THE ENTRY rather than the algorithm in force today — otherwise every entry predating the key reads as tampered on the day the key is introduced."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "Naive UTC ISO-8601 STRING, not a BSON date — `2026-08-25T04:19:33.123456`, with no `Z` and no offset. Ordering and the `from_time`/`to_time` filters are string comparisons over this shape, which is why they work at all; it is also why sending a differently-shaped timestamp shifts the boundary instead of failing."
          },
          "created_dt": {
            "type": "string",
            "format": "date-time",
            "description": "The same instant as `timestamp`, written additionally as a real date because a retention TTL index cannot be built on the string. Order and filter on `timestamp`; this field exists for the database, not for you."
          },
          "user_id": {
            "type": "string",
            "description": "The actor. Absent for entries written by the system rather than by a person."
          },
          "team_id": {
            "type": "string"
          },
          "feature": {
            "type": "string",
            "description": "Which subsystem acted. The full vocabulary is `GET /api/audit/features`; do not hard-code it, the set grows."
          },
          "action": {
            "type": "string",
            "description": "What was done. Vocabulary from `GET /api/audit/features`."
          },
          "severity": {
            "type": "string",
            "description": "Vocabulary from `GET /api/audit/features`."
          },
          "entity_type": {
            "type": "string"
          },
          "entity_id": {
            "type": "string"
          },
          "ip_address": {
            "type": "string",
            "description": "The caller's address as the edge reported it."
          },
          "user_agent": {
            "type": "string",
            "description": "As received, when the call site that logged the event had a request to read it from."
          },
          "device_id": {
            "type": "string",
            "description": "The recorded device, when the call site supplied one."
          },
          "correlation_id": {
            "type": "string",
            "description": "Groups this entry with the other entries written under the same id, and DEFAULTS TO THE ENTRY'S OWN `audit_id`. Be careful with this one: nothing currently feeds the request-scoped `X-Correlation-ID` response header into the audit logger, so an id lifted off a response you hold will not match anything here. Take the value from an entry you already fetched."
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "description": "Free-form, written by whichever subsystem logged the event. Shape varies by `feature`/`action` and is NOT part of this contract."
          }
        }
      },
      "AuditLogPage": {
        "type": "object",
        "description": "One page of audit entries. `logs`, `total`, `limit` and `skip` are always present on a successful response; the body is serialised with `exclude_unset`, which is what keeps `error` from appearing as a null on every normal page.",
        "properties": {
          "logs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditLogEntry"
            }
          },
          "total": {
            "type": "integer",
            "description": "Matching entries before paging."
          },
          "limit": {
            "type": "integer"
          },
          "skip": {
            "type": "integer",
            "description": "The offset that produced this page. The RESPONSE says `skip` even when you asked with `offset` — see the note on the request parameters."
          },
          "error": {
            "type": "string",
            "description": "RESERVED, and not emitted today. The query helper underneath has a no-team-scope branch answering `{logs: [], total: 0, error: \"No team access\"}`, but the route's guard rejects a caller who administers no team with 403 before that branch can be reached. Do not build your empty case around this field: an empty `logs` with no `error` is the empty case."
          }
        }
      },
      "CorrelatedEvents": {
        "type": "object",
        "description": "Every audit entry sharing one correlation id, oldest first.",
        "required": [
          "correlation_id",
          "events",
          "count"
        ],
        "properties": {
          "correlation_id": {
            "type": "string"
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditLogEntry"
            }
          },
          "count": {
            "type": "integer"
          }
        }
      },
      "AuditExportRequest": {
        "type": "object",
        "description": "All fields optional. Omitting `team_ids` exports the teams you administer; omitting the time bounds exports everything within the retained window.",
        "properties": {
          "team_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Restrict to these teams. Ids you do not administer are dropped from the request rather than rejected, so an export naming one team you can see and one you cannot succeeds and returns only the first. Follow that to its end: an export naming ONLY teams you cannot see is left with an empty scope and comes back 200 with `record_count: 0` — an empty export here is not evidence that nothing happened, so check the ids you sent."
          },
          "from_time": {
            "type": "string",
            "format": "date-time",
            "description": "Same lexicographic ISO-8601 string comparison as `GET /api/audit/logs`."
          },
          "to_time": {
            "type": "string",
            "format": "date-time",
            "description": "Same lexicographic ISO-8601 string comparison as `GET /api/audit/logs`."
          },
          "format": {
            "type": "string",
            "enum": [
              "json",
              "csv"
            ],
            "default": "json",
            "description": "Recorded on the export and echoed back. It does NOT change the response body — see the note on the operation."
          }
        }
      },
      "AuditExport": {
        "type": "object",
        "description": "The export, returned inline. Serialised with `exclude_unset`.",
        "properties": {
          "export_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifies this export in the audit log, which records it."
          },
          "format": {
            "type": "string",
            "description": "Echoes what you asked for."
          },
          "record_count": {
            "type": "integer",
            "description": "Rows in `data`. Equal to 10000 means you were truncated — narrow the time window and export again."
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditLogEntry"
            }
          },
          "exported_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AuditStats": {
        "type": "object",
        "description": "Counts over a trailing window. Serialised with `exclude_unset`.",
        "properties": {
          "period_days": {
            "type": "integer"
          },
          "total_events": {
            "type": "integer"
          },
          "by_feature": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Keyed by the `feature` vocabulary."
          },
          "by_severity": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Keyed by the `severity` vocabulary."
          }
        }
      },
      "AuditableFeatures": {
        "type": "object",
        "description": "The three closed vocabularies the filters accept. Fetch this rather than hard-coding the values — the sets grow with the product, and an unrecognised filter value is IGNORED rather than rejected, so a stale constant silently widens your query instead of failing it.",
        "required": [
          "features",
          "actions",
          "severities"
        ],
        "properties": {
          "features": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "actions": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "severities": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "CommentCountResponse": {
        "properties": {
          "count": {
            "type": "integer"
          },
          "subtask_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "task_id": {
            "type": "string"
          }
        },
        "required": [
          "task_id",
          "subtask_id",
          "count"
        ],
        "type": "object",
        "description": "A comment count for one task or subtask."
      },
      "CommentCreate": {
        "properties": {
          "content": {
            "maxLength": 5000,
            "minLength": 1,
            "type": "string"
          },
          "parent_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "subtask_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "task_id": {
            "type": "string"
          }
        },
        "required": [
          "task_id",
          "content"
        ],
        "type": "object",
        "description": "A new comment. `parent_id` makes it a reply."
      },
      "CommentCreatedResponse": {
        "properties": {
          "comment": {
            "additionalProperties": true,
            "type": "object"
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "message",
          "comment"
        ],
        "type": "object",
        "description": "The created comment, echoed back."
      },
      "CommentDeletedResponse": {
        "properties": {
          "comment_id": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "message",
          "comment_id"
        ],
        "type": "object",
        "description": "Which comment was deleted."
      },
      "CommentThreadResponse": {
        "properties": {
          "comments": {
            "items": {
              "additionalProperties": true,
              "type": "object"
            },
            "type": "array"
          },
          "has_more": {
            "type": "boolean"
          },
          "next_before": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "subtask_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "task_id": {
            "type": "string"
          },
          "total_count": {
            "type": "integer"
          }
        },
        "required": [
          "task_id",
          "subtask_id",
          "comments",
          "total_count",
          "has_more",
          "next_before"
        ],
        "type": "object",
        "description": "One page of a task's thread. `comments` is a tree: each element may carry its own `replies` array of the same shape."
      },
      "CommentUpdate": {
        "properties": {
          "content": {
            "maxLength": 5000,
            "minLength": 1,
            "type": "string"
          }
        },
        "required": [
          "content"
        ],
        "type": "object",
        "description": "The replacement body of a comment."
      },
      "CommentUpdatedResponse": {
        "properties": {
          "comment_id": {
            "type": "string"
          },
          "content": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "updated_at": {
            "type": "string"
          }
        },
        "required": [
          "message",
          "comment_id",
          "content",
          "updated_at"
        ],
        "type": "object",
        "description": "The comment's new content and edit timestamp."
      },
      "MessageResponse": {
        "properties": {
          "message": {
            "type": "string"
          }
        },
        "required": [
          "message"
        ],
        "type": "object",
        "description": "A bare confirmation: `{\"message\": \"...\"}`."
      },
      "Note": {
        "properties": {
          "content": {
            "type": "string"
          },
          "project_id": {
            "type": "string"
          },
          "tags": {
            "default": [],
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "title": {
            "type": "string"
          }
        },
        "required": [
          "title",
          "content",
          "project_id"
        ],
        "type": "object",
        "description": "The body of a note create or replace. Every field is authoritative on a replace."
      },
      "NoteRow": {
        "additionalProperties": true,
        "properties": {
          "content": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "created_at": {
            "anyOf": [
              {},
              {
                "type": "null"
              }
            ]
          },
          "created_by": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "note_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "project_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "tags": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ]
          },
          "team_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "title": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "updated_at": {
            "anyOf": [
              {},
              {
                "type": "null"
              }
            ]
          }
        },
        "type": "object",
        "description": "A stored note. `team_id` is derived from the project and is not settable."
      }
    }
  }
}