{
  "openapi": "3.1.0",
  "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
  "info": {
    "title": "gant-to API",
    "version": "1.0.0",
    "summary": "Gantt charts as data, designed to be driven by AI agents.",
    "description": "REST API for the gant-to Gantt viewer and editor.\n\nEvery body is JSON, UTF-8. Dates are `YYYY-MM-DD`. IDs are UUID strings. Timestamps are RFC3339. Unknown or misspelled properties are rejected with `400 invalid_request` rather than ignored, so every schema here is closed (`additionalProperties: false`). Request bodies are capped at 4 MB.\n\nAgents should authenticate with an API key (`Authorization: Bearer gt_live_...`) and drive charts through the three Gantt routes:\n\n- `GET /v1/projects/{projectID}/gantt` reads the whole chart.\n- `PUT /v1/projects/{projectID}/gantt` means \"the chart is exactly this\".\n- `POST /v1/projects/{projectID}/tasks:batch` means \"apply these changes\".\n\nThe two bulk writes differ at the FIELD level, not only at the row level:\n\n| | `PUT /gantt` | `POST /tasks:batch` |\n|---|---|---|\n| task in DB, absent from body | deleted | left alone |\n| field omitted on a task in the body | reset to its default | left alone |\n| `parent_id`/`parent_ref` omitted | moved to top level | left alone |\n| dependencies | replaced wholesale | upserted, none removed |\n| `name`, `start_date`, `end_date` | required on every task | required only on new tasks |\n\nSo an agent that assumes batch semantics for `PUT` will silently clear the assignee, progress, notes, colour and ref of every task it did send, and delete every task it did not. An agent editing one date wants the batch route; an agent regenerating a plan from scratch wants `PUT`.\n\nBoth bulk writes are a single transaction: if any task or dependency in the payload is rejected, nothing is written at all. Both return the whole chart afterwards, so no follow-up read is needed.\n\nThe `ref` field on a task is a caller-supplied stable key, unique per project. It lets an agent write a chart and wire its parents and dependencies in a single request without knowing server-generated UUIDs. A `ref` that resolves to no task -- in `parent_ref`, `predecessor_ref` or `successor_ref` -- is `400 invalid_request`, not `404`, because it is a mistake in the payload rather than a missing resource.",
    "contact": {
      "name": "gant-to",
      "url": "https://github.com/0xa1/gant-to"
    },
    "license": {
      "name": "Proprietary",
      "identifier": "LicenseRef-Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://api.gantt-to.work",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "health",
      "description": "Liveness and readiness."
    },
    {
      "name": "auth",
      "description": "Google OAuth sign-in, sessions and the dev login shortcut."
    },
    {
      "name": "api-keys",
      "description": "Long-lived bearer tokens for agents and scripts."
    },
    {
      "name": "orgs",
      "description": "Organizations. The member list comes back from `GET /v1/orgs/{orgID}`."
    },
    {
      "name": "members",
      "description": "Adding people to an organization, changing their role and removing them. Invitations are by email and nothing is emailed."
    },
    {
      "name": "projects",
      "description": "Projects inside an organization."
    },
    {
      "name": "gantt",
      "description": "Whole-chart reads and writes. The AI-facing workhorse."
    },
    {
      "name": "tasks",
      "description": "Fine-grained task edits."
    },
    {
      "name": "dependencies",
      "description": "Task dependency edges."
    },
    {
      "name": "public",
      "description": "Unauthenticated reads of public projects."
    },
    {
      "name": "discovery",
      "description": "Machine-readable descriptions of this API."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "cookieAuth": []
    }
  ],
  "paths": {
    "/healthz": {
      "get": {
        "tags": [
          "health"
        ],
        "operationId": "health",
        "summary": "Health check",
        "description": "Reports process liveness and database reachability. No authentication.",
        "security": [],
        "responses": {
          "200": {
            "description": "Service is up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "status": "ok",
                  "db": "ok"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      }
    },
    "/v1/auth/google/start": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "googleStart",
        "summary": "Begin Google sign-in",
        "description": "Redirects the browser to the Google consent screen. Returns `501` when this deployment has no Google sign-in configured. Browsers only; an API client cannot follow this flow.",
        "security": [],
        "parameters": [
          {
            "name": "redirect",
            "in": "query",
            "required": false,
            "description": "Path within the web app to return to after login.",
            "schema": {
              "type": "string",
              "default": "/"
            },
            "example": "/orgs/acme"
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the Google consent screen.",
            "headers": {
              "Location": {
                "description": "Google OAuth authorization URL.",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "501": {
            "$ref": "#/components/responses/NotImplemented"
          }
        }
      }
    },
    "/v1/auth/google/callback": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "googleCallback",
        "summary": "Google OAuth callback",
        "description": "Exchanges the authorization code, creates or updates the user, sets the `gt_session` cookie and redirects into the web app. Called by Google, not by a client.",
        "security": [],
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "required": true,
            "description": "Authorization code issued by Google.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": true,
            "description": "Opaque state minted by `/v1/auth/google/start`; carries the post-login redirect path.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Session cookie set; redirect into the web app.",
            "headers": {
              "Location": {
                "description": "Where in the web app to continue.",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              },
              "Set-Cookie": {
                "description": "`gt_session` JWT. HttpOnly, SameSite=Lax, Secure in production.",
                "schema": {
                  "type": "string"
                },
                "example": "gt_session=eyJhbGciOiJIUzI1NiIs...; Path=/; HttpOnly; SameSite=Lax"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "501": {
            "$ref": "#/components/responses/NotImplemented"
          }
        }
      }
    },
    "/v1/auth/logout": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "logout",
        "summary": "Log out",
        "description": "Clears the `gt_session` cookie. Always succeeds, whether or not a session was present.",
        "security": [],
        "responses": {
          "204": {
            "description": "Cookie cleared.",
            "headers": {
              "Set-Cookie": {
                "description": "`gt_session` expired.",
                "schema": {
                  "type": "string"
                },
                "example": "gt_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"
              }
            }
          }
        }
      }
    },
    "/v1/me": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "me",
        "summary": "Current identity",
        "description": "Returns the authenticated user and every organization they belong to, with the caller's role on each.",
        "responses": {
          "200": {
            "description": "The caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeResponse"
                },
                "example": {
                  "user": {
                    "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
                    "email": "owner@example.com",
                    "name": "Ada Lovelace",
                    "avatar_url": "https://lh3.googleusercontent.com/a/default-user",
                    "plan": "free",
                    "created_at": "2026-08-20T18:31:44Z"
                  },
                  "orgs": [
                    {
                      "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
                      "slug": "acme",
                      "name": "Acme Inc",
                      "owner_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
                      "role": "owner",
                      "created_at": "2026-08-21T09:00:00Z",
                      "updated_at": "2026-08-21T09:00:00Z"
                    }
                  ],
                  "limits": {
                    "orgs": 1,
                    "projects_per_org": 3,
                    "tasks_per_project": 50,
                    "api_keys": 2,
                    "seats": 1
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/api-keys": {
      "post": {
        "tags": [
          "api-keys"
        ],
        "operationId": "createAPIKey",
        "summary": "Mint an API key",
        "description": "Session authentication only. An API key cannot mint further API keys, so agents must be handed a key created from a browser session. The plaintext token is returned once and never again; only `sha256(token)` is stored.",
        "security": [
          {
            "cookieAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/APIKeyInput"
              },
              "example": {
                "name": "claude"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created. Store `token` now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/APIKeyCreated"
                },
                "example": {
                  "id": "9c5b94b1-35ad-49bb-b118-8e8fc24abf80",
                  "name": "claude",
                  "prefix": "gt_live_BKW_",
                  "token": "gt_live_BKW_7QmXbT2sVh0KpL9dRcAe4Nf1Jy-z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "get": {
        "tags": [
          "api-keys"
        ],
        "operationId": "listAPIKeys",
        "summary": "List API keys",
        "description": "Lists the caller's keys. Tokens are never returned again; only the `prefix` is shown so a key can be identified.",
        "security": [
          {
            "cookieAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's keys.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/APIKeyList"
                },
                "example": {
                  "api_keys": [
                    {
                      "id": "9c5b94b1-35ad-49bb-b118-8e8fc24abf80",
                      "name": "claude",
                      "prefix": "gt_live_BKW_",
                      "last_used_at": "2026-08-21T10:14:02Z",
                      "created_at": "2026-08-20T18:31:44Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/v1/api-keys/{keyID}": {
      "parameters": [
        {
          "name": "keyID",
          "in": "path",
          "required": true,
          "description": "API key ID.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "tags": [
          "api-keys"
        ],
        "operationId": "revokeAPIKey",
        "summary": "Revoke an API key",
        "description": "Session authentication only. The key stops authenticating immediately.",
        "security": [
          {
            "cookieAuth": []
          }
        ],
        "responses": {
          "204": {
            "description": "Revoked."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/orgs": {
      "post": {
        "tags": [
          "orgs"
        ],
        "operationId": "createOrg",
        "summary": "Create an organization",
        "description": "The caller becomes the organization's `owner`. Counted against the caller's own plan limit for organizations owned.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrgInput"
              },
              "example": {
                "name": "Acme Inc",
                "slug": "acme"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Organization created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgResponse"
                },
                "example": {
                  "org": {
                    "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
                    "slug": "acme",
                    "name": "Acme Inc",
                    "owner_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
                    "role": "owner",
                    "created_at": "2026-08-21T09:00:00Z",
                    "updated_at": "2026-08-21T09:00:00Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "get": {
        "tags": [
          "orgs"
        ],
        "operationId": "listOrgs",
        "summary": "List organizations",
        "description": "Every organization the caller is a member of, with the caller's role.",
        "responses": {
          "200": {
            "description": "Organizations the caller belongs to.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/orgs/{orgID}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrgID"
        }
      ],
      "get": {
        "tags": [
          "orgs"
        ],
        "operationId": "getOrg",
        "summary": "Read an organization",
        "description": "Requires `viewer` or above. Non-members get `404` so existence does not leak. The `members` array here is the only way to read the roster; changes go through `POST`, `PATCH` and `DELETE` on `/v1/orgs/{orgID}/members`.",
        "responses": {
          "200": {
            "description": "The organization and its members.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgDetail"
                },
                "example": {
                  "org": {
                    "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
                    "slug": "acme",
                    "name": "Acme Inc",
                    "owner_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
                    "role": "owner",
                    "created_at": "2026-08-21T09:00:00Z",
                    "updated_at": "2026-08-21T09:00:00Z"
                  },
                  "members": [
                    {
                      "user_id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
                      "email": "owner@example.com",
                      "name": "Ada Lovelace",
                      "role": "owner"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "orgs"
        ],
        "operationId": "updateOrg",
        "summary": "Update an organization",
        "description": "Requires `admin` or above. Only `name` is mutable; the slug is fixed at creation.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrgPatch"
              },
              "example": {
                "name": "Acme Incorporated"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "orgs"
        ],
        "operationId": "deleteOrg",
        "summary": "Delete an organization",
        "description": "Requires role `owner`. Cascades to projects, tasks and dependencies.",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/orgs/{orgID}/projects": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrgID"
        }
      ],
      "post": {
        "tags": [
          "projects"
        ],
        "operationId": "createProject",
        "summary": "Create a project",
        "description": "Requires `member` or above. The projects-per-org limit is checked against the organization owner's plan, not the caller's.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProjectInput"
              },
              "example": {
                "name": "Apollo Launch",
                "slug": "apollo",
                "description": "Q3 go-to-market plan",
                "visibility": "private"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Project created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectResponse"
                },
                "example": {
                  "project": {
                    "id": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01",
                    "org_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
                    "org_slug": "acme",
                    "slug": "apollo",
                    "name": "Apollo Launch",
                    "description": "Q3 go-to-market plan",
                    "visibility": "private",
                    "created_at": "2026-08-21T09:05:00Z",
                    "updated_at": "2026-08-21T09:05:00Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "get": {
        "tags": [
          "projects"
        ],
        "operationId": "listProjects",
        "summary": "List projects in an organization",
        "description": "Requires `viewer` or above.",
        "responses": {
          "200": {
            "description": "Projects in the organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/projects/{projectID}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectID"
        }
      ],
      "get": {
        "tags": [
          "projects"
        ],
        "operationId": "getProject",
        "summary": "Read a project",
        "description": "Requires `viewer` or above in the owning organization. A non-member asking for a private project gets `404`, not `403`, so existence does not leak. Public projects are also readable without authentication through `/v1/public/{orgSlug}/{projectSlug}/gantt`.",
        "responses": {
          "200": {
            "description": "The project.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "projects"
        ],
        "operationId": "updateProject",
        "summary": "Update a project",
        "description": "Requires `member` or above. Send only the fields you want to change. Flipping `visibility` to `public` exposes the chart through the public route.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProjectPatch"
              },
              "example": {
                "visibility": "public"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated project.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "delete": {
        "tags": [
          "projects"
        ],
        "operationId": "deleteProject",
        "summary": "Delete a project",
        "description": "Requires `member` or above. Cascades to tasks and dependencies.",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/projects/{projectID}/gantt": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectID"
        }
      ],
      "get": {
        "tags": [
          "gantt"
        ],
        "operationId": "getGantt",
        "summary": "Read the whole chart",
        "description": "Returns the project, every task and every dependency in one document. Requires `viewer` or above.",
        "responses": {
          "200": {
            "description": "The chart.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Gantt"
                },
                "examples": {
                  "apollo": {
                    "summary": "A three-task chart with two dependencies",
                    "value": {
                      "project": {
                        "id": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01",
                        "org_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
                        "org_slug": "acme",
                        "slug": "apollo",
                        "name": "Apollo Launch",
                        "description": "Q3 go-to-market plan",
                        "visibility": "private",
                        "created_at": "2026-08-21T09:05:00Z",
                        "updated_at": "2026-08-21T09:05:00Z"
                      },
                      "tasks": [
                        {
                          "id": "c0a8012e-1f4b-4d7a-9f10-6b1e0d2a7701",
                          "ref": "design",
                          "project_id": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01",
                          "parent_id": null,
                          "name": "Design",
                          "notes": "Wireframes plus design review",
                          "start_date": "2026-09-01",
                          "end_date": "2026-09-07",
                          "progress": 100,
                          "color": "#3b82f6",
                          "assignee": "ada@example.com",
                          "is_milestone": false,
                          "sort_order": 1
                        },
                        {
                          "id": "c0a8012e-1f4b-4d7a-9f10-6b1e0d2a7702",
                          "ref": "build",
                          "project_id": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01",
                          "parent_id": null,
                          "name": "Build",
                          "notes": "",
                          "start_date": "2026-09-08",
                          "end_date": "2026-09-25",
                          "progress": 40,
                          "color": "#22c55e",
                          "assignee": "grace@example.com",
                          "is_milestone": false,
                          "sort_order": 2
                        },
                        {
                          "id": "c0a8012e-1f4b-4d7a-9f10-6b1e0d2a7703",
                          "ref": "launch",
                          "project_id": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01",
                          "parent_id": null,
                          "name": "Launch",
                          "notes": "",
                          "start_date": "2026-09-30",
                          "end_date": "2026-09-30",
                          "progress": 0,
                          "color": "",
                          "assignee": "",
                          "is_milestone": true,
                          "sort_order": 3
                        }
                      ],
                      "dependencies": [
                        {
                          "id": "d47ac10b-58cc-4372-a567-0e02b2c3d401",
                          "project_id": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01",
                          "predecessor_id": "c0a8012e-1f4b-4d7a-9f10-6b1e0d2a7701",
                          "successor_id": "c0a8012e-1f4b-4d7a-9f10-6b1e0d2a7702",
                          "type": "FS",
                          "lag_days": 0
                        },
                        {
                          "id": "d47ac10b-58cc-4372-a567-0e02b2c3d402",
                          "project_id": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01",
                          "predecessor_id": "c0a8012e-1f4b-4d7a-9f10-6b1e0d2a7702",
                          "successor_id": "c0a8012e-1f4b-4d7a-9f10-6b1e0d2a7703",
                          "type": "FS",
                          "lag_days": 2
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "gantt"
        ],
        "operationId": "replaceGantt",
        "summary": "Replace the whole chart (declarative: deletes omitted tasks AND resets omitted fields)",
        "description": "`PUT /gantt` means \"the chart is exactly this\". Applies the entire body in one transaction.\n\n**Row level.** A task with `id` updates that task. A task with `ref` but no `id` updates the task with that `ref` in this project, or inserts one if none exists. A task in the database whose `id` and `ref` both go unmentioned is DELETED, along with its children and every dependency touching it. `dependencies` is replaced wholesale: omitting the key, or sending an empty array, deletes every existing edge.\n\n**Field level.** This is the part that catches agents out. On a task you DID include, any field you omit is reset to its column default: `notes` to `\"\"`, `progress` to `0`, `color` to `\"\"`, `assignee` to `\"\"`, `is_milestone` to `false`, `sort_order` to `0`, `ref` to `null`, and an omitted `parent_id`/`parent_ref` moves the task to the top level. So every task object must be complete. `name` and `start_date` are required on every task, and `end_date` too unless `is_milestone` is `true`.\n\n**Use it when** you generated the whole plan, or when you read the chart with `GET /gantt`, edited the objects you got back, and are sending all of them. To add or change a few tasks without touching the rest, use `POST /v1/projects/{projectID}/tasks:batch` instead.\n\n`parent_ref`, `predecessor_ref` and `successor_ref` resolve against `ref` values in the same request first, then against `ref` values already stored in the project, so a whole hierarchy can be written in one call. A ref that resolves to nothing is `400`, not `404`. One transaction: if any element is rejected, nothing is written. Any `project` key is accepted and ignored. Requires `member` or above.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GanttInput"
              },
              "examples": {
                "wholeChartWithRefs": {
                  "summary": "Write a complete chart from scratch using ref keys",
                  "description": "Every task carries every field, which is what a declarative replace expects.",
                  "value": {
                    "tasks": [
                      {
                        "ref": "phase-1",
                        "name": "Phase 1",
                        "notes": "",
                        "start_date": "2026-09-01",
                        "end_date": "2026-09-25",
                        "progress": 0,
                        "color": "",
                        "assignee": "",
                        "is_milestone": false,
                        "sort_order": 1
                      },
                      {
                        "ref": "design",
                        "parent_ref": "phase-1",
                        "name": "Design",
                        "notes": "Wireframes plus design review",
                        "start_date": "2026-09-01",
                        "end_date": "2026-09-07",
                        "progress": 100,
                        "color": "#3b82f6",
                        "assignee": "ada@example.com",
                        "is_milestone": false,
                        "sort_order": 2
                      },
                      {
                        "ref": "build",
                        "parent_ref": "phase-1",
                        "name": "Build",
                        "notes": "",
                        "start_date": "2026-09-08",
                        "end_date": "2026-09-25",
                        "progress": 40,
                        "color": "#22c55e",
                        "assignee": "grace@example.com",
                        "is_milestone": false,
                        "sort_order": 3
                      },
                      {
                        "ref": "launch",
                        "name": "Launch",
                        "notes": "",
                        "start_date": "2026-09-30",
                        "end_date": "2026-09-30",
                        "progress": 0,
                        "color": "",
                        "assignee": "",
                        "is_milestone": true,
                        "sort_order": 4
                      }
                    ],
                    "dependencies": [
                      {
                        "predecessor_ref": "design",
                        "successor_ref": "build",
                        "type": "FS",
                        "lag_days": 0
                      },
                      {
                        "predecessor_ref": "build",
                        "successor_ref": "launch",
                        "type": "FS",
                        "lag_days": 2
                      }
                    ]
                  }
                },
                "dangerousPartialBody": {
                  "summary": "What NOT to send: a partial body silently destroys data",
                  "description": "Sending only the task you changed does two damaging things at once. Every other task in the project is deleted, and on `build` itself the omitted `ref`, `assignee`, `progress`, `color`, `notes`, `sort_order` and parent are all reset, so it loses its ref, its owner, its 40 percent progress and its place under `phase-1`. Use `POST /tasks:batch` for this edit.",
                  "value": {
                    "tasks": [
                      {
                        "name": "Build",
                        "start_date": "2026-09-08",
                        "end_date": "2026-09-27"
                      }
                    ]
                  }
                },
                "emptyChart": {
                  "summary": "Delete every task and dependency in the project",
                  "value": {
                    "tasks": [],
                    "dependencies": []
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The chart as stored after the replace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Gantt"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/projects/{projectID}/tasks:batch": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectID"
        }
      ],
      "post": {
        "tags": [
          "gantt"
        ],
        "operationId": "batchUpsertTasks",
        "summary": "Apply incremental changes (additive upsert: never deletes, never resets)",
        "description": "`POST /tasks:batch` means \"apply these changes\". Additive upsert in one transaction, and the route to prefer for almost every AI edit.\n\n**Row level.** Tasks are matched by `id`, else by `ref`; anything unmatched is inserted. Tasks and dependencies that already exist and are not mentioned are left completely alone. Nothing is ever deleted.\n\n**Field level.** A field you omit keeps its stored value, and an omitted `parent_id`/`parent_ref` leaves the existing parent alone. So you can send a task carrying just its `ref` and one new date. `name`, `start_date` and `end_date` are required only for a task that does not exist yet.\n\nListed dependencies are upserted: a new edge is inserted, and an edge that already exists between the same two tasks has its `type` and `lag_days` overwritten. No edge is removed. Supply at least one task or one dependency.\n\nWithin a request, `parent_ref`, `predecessor_ref` and `successor_ref` resolve against `ref` values in the same body first, then against `ref` values already stored in the project. A ref that resolves to nothing is `400`, not `404`. One transaction: if any element is rejected, nothing is written. Requires `member` or above.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchInput"
              },
              "examples": {
                "addOneTask": {
                  "summary": "Append a QA task between build and launch, leaving the rest alone",
                  "value": {
                    "tasks": [
                      {
                        "ref": "qa",
                        "name": "QA",
                        "start_date": "2026-09-26",
                        "end_date": "2026-09-29",
                        "sort_order": 5
                      }
                    ],
                    "dependencies": [
                      {
                        "predecessor_ref": "build",
                        "successor_ref": "qa"
                      },
                      {
                        "predecessor_ref": "qa",
                        "successor_ref": "launch"
                      }
                    ]
                  }
                },
                "changeOneField": {
                  "summary": "Move one task's end date, touching nothing else",
                  "description": "Identify the task by `ref` and send only what changes. `name`, `assignee`, `progress`, `color`, `notes`, `sort_order` and the parent link all keep their stored values. The same body sent to `PUT /gantt` would clear them and delete every other task.",
                  "value": {
                    "tasks": [
                      {
                        "ref": "build",
                        "end_date": "2026-09-27"
                      }
                    ]
                  }
                },
                "retimeADependency": {
                  "summary": "Overwrite the lag on an edge that already exists",
                  "description": "An edge is unique per predecessor and successor, so this updates rather than duplicating.",
                  "value": {
                    "dependencies": [
                      {
                        "predecessor_ref": "build",
                        "successor_ref": "launch",
                        "type": "FS",
                        "lag_days": 5
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The whole chart after the upsert.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Gantt"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/projects/{projectID}/tasks": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectID"
        }
      ],
      "post": {
        "tags": [
          "tasks"
        ],
        "operationId": "createTask",
        "summary": "Create one task",
        "description": "Requires `member` or above. Counted against the tasks-per-project limit of the organization owner's plan.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaskInput"
              },
              "example": {
                "ref": "docs",
                "name": "Write docs",
                "start_date": "2026-09-20",
                "end_date": "2026-09-24",
                "assignee": "ada@example.com"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Task created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/v1/tasks/{taskID}": {
      "parameters": [
        {
          "name": "taskID",
          "in": "path",
          "required": true,
          "description": "Task ID.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "tags": [
          "tasks"
        ],
        "operationId": "updateTask",
        "summary": "Update one task",
        "description": "Partial update. Send only the fields you want to change. Requires `member` or above.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaskPatch"
              },
              "example": {
                "progress": 60,
                "end_date": "2026-09-27"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated task.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "delete": {
        "tags": [
          "tasks"
        ],
        "operationId": "deleteTask",
        "summary": "Delete one task",
        "description": "Cascades to the task's children and to any dependency edge touching it. Requires `member` or above.",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/projects/{projectID}/dependencies": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectID"
        }
      ],
      "post": {
        "tags": [
          "dependencies"
        ],
        "operationId": "createDependency",
        "summary": "Create one dependency",
        "description": "Identify each end by `id` or by `ref`. A dependency that would close a cycle is rejected with `409` and the message `dependency would create a cycle`. Requires `member` or above.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DepInput"
              },
              "example": {
                "predecessor_ref": "design",
                "successor_ref": "build",
                "type": "FS",
                "lag_days": 0
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Dependency created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DependencyResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The edge already exists, or it would create a cycle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "error": {
                    "code": "conflict",
                    "message": "dependency would create a cycle"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/dependencies/{depID}": {
      "parameters": [
        {
          "name": "depID",
          "in": "path",
          "required": true,
          "description": "Dependency ID.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "tags": [
          "dependencies"
        ],
        "operationId": "deleteDependency",
        "summary": "Delete one dependency",
        "description": "Requires `member` or above.",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/public/{orgSlug}/{projectSlug}/gantt": {
      "parameters": [
        {
          "name": "orgSlug",
          "in": "path",
          "required": true,
          "description": "Organization slug.",
          "schema": {
            "type": "string"
          },
          "example": "acme"
        },
        {
          "name": "projectSlug",
          "in": "path",
          "required": true,
          "description": "Project slug, unique within the organization.",
          "schema": {
            "type": "string"
          },
          "example": "apollo"
        }
      ],
      "get": {
        "tags": [
          "public"
        ],
        "operationId": "getPublicGantt",
        "summary": "Read a public chart without authentication",
        "description": "Returns the chart when the project's `visibility` is `public`. A private or missing project answers `404` either way, so visibility cannot be probed.",
        "security": [],
        "responses": {
          "200": {
            "description": "The public chart.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Gantt"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/openapi.json": {
      "get": {
        "tags": [
          "discovery"
        ],
        "operationId": "openapi",
        "summary": "This document",
        "description": "Serves this OpenAPI 3.1 document. No authentication.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "tags": [
          "discovery"
        ],
        "operationId": "llmsTxt",
        "summary": "Plain-text cheat sheet for AI agents",
        "description": "A dense plain-text summary of every route, the authentication scheme, the error envelope, the plan limits and a worked example. No authentication.",
        "security": [],
        "responses": {
          "200": {
            "description": "The cheat sheet.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/v1/orgs/{orgID}/members": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrgID"
        }
      ],
      "post": {
        "tags": [
          "members"
        ],
        "operationId": "inviteMember",
        "summary": "Add someone to an organization by email",
        "description": "Adds a membership and returns it. Requires `admin` or above in the organization, and only the `owner` may grant the `admin` role.\n\nThe invitee is named by EMAIL, not user id, so someone without an account can be added: a placeholder user row is created and claimed on their first Google sign-in with the same address. **Nothing is emailed.** Tell them out of band.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberInviteInput"
              },
              "examples": {
                "member": {
                  "summary": "Add a collaborator who can edit charts",
                  "value": {
                    "email": "invitee@example.test",
                    "role": "member"
                  }
                },
                "viewer": {
                  "summary": "Read-only access",
                  "value": {
                    "email": "stakeholder@example.test",
                    "role": "viewer"
                  }
                },
                "admin": {
                  "summary": "Owner only: another administrator",
                  "value": {
                    "email": "deputy@example.test",
                    "role": "admin"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Membership created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberResponse"
                },
                "example": {
                  "member": {
                    "user_id": "75d87687-5d7d-4815-9c6a-4692fce688be",
                    "email": "invitee@example.test",
                    "name": "invitee",
                    "role": "member"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRole"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/SeatLimit"
          },
          "403": {
            "$ref": "#/components/responses/AdminManagementForbidden"
          },
          "404": {
            "$ref": "#/components/responses/MemberNotInOrg"
          },
          "409": {
            "description": "That person is already a member. Use `PATCH` to change an existing member's role.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "error": {
                    "code": "conflict",
                    "message": "that person is already a member; use PATCH to change their role"
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      }
    },
    "/v1/orgs/{orgID}/members/{userID}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/OrgID"
        },
        {
          "$ref": "#/components/parameters/UserID"
        }
      ],
      "patch": {
        "tags": [
          "members"
        ],
        "operationId": "updateMemberRole",
        "summary": "Change a member's role",
        "description": "Requires `admin` or above. `role` is required; there is no partial update.\n\nTwo rules constrain who you may re-role. The organization owner's membership mirrors `organizations.owner_id` and cannot be changed at all (`409`). And only the `owner` may touch an `admin`, whether that means promoting someone to admin or re-roling an existing admin (`403`) -- so an admin cannot demote a peer.\n\nDoes not consume a seat, since the roster size is unchanged. Accepts either authentication mechanism.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberRoleInput"
              },
              "example": {
                "role": "viewer"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The membership after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberResponse"
                },
                "example": {
                  "member": {
                    "user_id": "75d87687-5d7d-4815-9c6a-4692fce688be",
                    "email": "invitee@example.test",
                    "name": "invitee",
                    "role": "viewer"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRole"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/AdminManagementForbidden"
          },
          "404": {
            "$ref": "#/components/responses/MemberNotInOrg"
          },
          "409": {
            "description": "The target is the organization owner, whose role cannot be changed. Ownership transfer is not implemented.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "error": {
                    "code": "conflict",
                    "message": "the organization owner's role cannot be changed"
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      },
      "delete": {
        "tags": [
          "members"
        ],
        "operationId": "removeMember",
        "summary": "Remove a member, or leave the organization yourself",
        "description": "Removing SOMEONE ELSE requires `admin` or above, and only the `owner` may remove an `admin`. Removing YOURSELF is allowed from any role, including `viewer` -- the required role drops when `userID` equals your own user id, so anyone can leave.\n\nThe organization owner can never be removed, including by themselves, because it would strand the organization: `409`. Transfer ownership first, which is not implemented, so in practice delete the organization instead with `DELETE /v1/orgs/{orgID}`.\n\nFrees a seat. Accepts either authentication mechanism.",
        "responses": {
          "204": {
            "description": "Membership removed. No body."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/AdminManagementForbidden"
          },
          "404": {
            "$ref": "#/components/responses/MemberNotInOrg"
          },
          "409": {
            "description": "The target is the organization owner, who cannot be removed even by themselves.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "error": {
                    "code": "conflict",
                    "message": "the organization owner cannot be removed; transfer ownership first"
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "cookieAuth": {
        "type": "apiKey",
        "in": "cookie",
        "name": "gt_session",
        "description": "Session cookie issued by the Google OAuth callback. Used by the web app; an API key is the mechanism for scripts and agents. HttpOnly, Secure in production."
      },
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key: `Authorization: Bearer gt_live_<random>`. Created by `POST /v1/api-keys` from a browser session and shown once. The server stores only hex `sha256(token)`. This is the mechanism agents and scripts should use, everywhere except the API key routes themselves."
      }
    },
    "parameters": {
      "OrgID": {
        "name": "orgID",
        "in": "path",
        "required": true,
        "description": "Organization ID.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
      },
      "ProjectID": {
        "name": "projectID",
        "in": "path",
        "required": true,
        "description": "Project ID.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "8f14e45f-ea3f-4c1b-9c4f-2b7f4d6a9c01"
      },
      "UserID": {
        "name": "userID",
        "in": "path",
        "required": true,
        "description": "The `user_id` of the membership being acted on, as returned in `Member.user_id`.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "75d87687-5d7d-4815-9c6a-4692fce688be"
      }
    },
    "responses": {
      "InvalidRequest": {
        "description": "Malformed body, an unknown or misspelled property, an unparsable date, a value outside its allowed range, a missing required field, or a `ref` that resolves to no task. In a bulk write the message names the offending element by index, as `tasks[3]: ...` or `dependencies[0]: ...`; a shape-validation failure on the first element drops the prefix, so parse the index when present rather than requiring it. Nothing was written.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "missingField": {
                "summary": "A required field is absent on the fourth task",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "tasks[3]: end_date is required",
                    "field": "end_date"
                  }
                }
              },
              "unresolvedDependencyRef": {
                "summary": "A dependency names a ref that matches no task",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "dependencies[0]: invalid value: successor could not be resolved to a task in this project"
                  }
                }
              },
              "unresolvedParentRef": {
                "summary": "A task names a parent_ref that matches no task",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "tasks[0]: invalid value: parent_ref \"ghost\" matches no task in this payload or project"
                  }
                }
              },
              "unknownField": {
                "summary": "A misspelled property is rejected, not ignored",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "could not parse the request body: json: unknown field \"colour\""
                  }
                }
              },
              "emptyBatch": {
                "summary": "A tasks:batch body with nothing in it",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "supply at least one task or dependency",
                    "field": "tasks"
                  }
                }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "No credentials, or credentials that do not resolve to a user.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "error": {
                "code": "unauthorized",
                "message": "sign in or present an API key to continue"
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated, but the caller's role in the organization is too low for this action.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "error": {
                "code": "forbidden",
                "message": "this action needs the member role in the organization"
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "The resource does not exist, or the caller may not know that it does.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "error": {
                "code": "not_found",
                "message": "project not found"
              }
            }
          }
        }
      },
      "Conflict": {
        "description": "A uniqueness constraint or an invariant was violated: a taken slug, a dependency cycle, or a task depending on itself. In a bulk write nothing was written.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "slugTaken": {
                "summary": "A slug is already in use",
                "value": {
                  "error": {
                    "code": "conflict",
                    "message": "that value is already taken"
                  }
                }
              },
              "cycle": {
                "summary": "A dependency would close a loop",
                "value": {
                  "error": {
                    "code": "conflict",
                    "message": "dependency would create a cycle"
                  }
                }
              }
            }
          }
        }
      },
      "PlanLimit": {
        "description": "An account limit was reached. The message names the limit.\n\nLimits on organization-scoped resources - projects per organization, tasks per project, seats - are measured against the organization's owner, not the caller. Read the limits that apply to you from `GET /v1/me`, where `-1` means unlimited.\n\nA `402` never means data was lost. It refuses one new thing; everything that already exists stays readable and editable, including anything sitting above a limit after it changes. Retrying will not clear it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "error": {
                "code": "plan_limit",
                "message": "the free plan allows 50 tasks per project; upgrade to pro to raise this limit"
              }
            }
          }
        }
      },
      "NotImplemented": {
        "description": "Google sign-in is not configured on this deployment. The envelope carries code `internal`, because the error vocabulary has no code for `501`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "error": {
                "code": "internal",
                "message": "Google sign-in is not configured on this deployment"
              }
            }
          }
        }
      },
      "Internal": {
        "description": "Unexpected server-side failure.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "error": {
                "code": "internal",
                "message": "something went wrong on our side"
              }
            }
          }
        }
      },
      "MemberNotInOrg": {
        "description": "Either the target is not a member of this organization, or the CALLER is not a member of it. Both answer `404` rather than `403` so neither the organization's existence nor its roster leaks to an outsider.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "targetNotMember": {
                "summary": "The user id is not in this organization",
                "value": {
                  "error": {
                    "code": "not_found",
                    "message": "that person is not a member of this organization"
                  }
                }
              },
              "callerNotMember": {
                "summary": "The caller cannot see this organization at all",
                "value": {
                  "error": {
                    "code": "not_found",
                    "message": "organization not found"
                  }
                }
              }
            }
          }
        }
      },
      "AdminManagementForbidden": {
        "description": "The caller's role is too low. Either they are not `admin` or above in the organization, or they are an admin trying to act on an admin: only the `owner` may add, re-role or remove an `admin`, which stops two admins demoting each other in a loop.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "adminOnOwner": {
                "summary": "An admin tried to act on an admin",
                "value": {
                  "error": {
                    "code": "forbidden",
                    "message": "only the organization owner can manage admins"
                  }
                }
              },
              "roleTooLow": {
                "summary": "The caller is a member or viewer",
                "value": {
                  "error": {
                    "code": "forbidden",
                    "message": "this action needs the admin role in the organization"
                  }
                }
              }
            }
          }
        }
      },
      "InvalidRole": {
        "description": "`role` was absent, empty, wrongly cased, padded, or `owner`. Two distinct messages: a generic one, and a specific one whenever the value case-insensitively trims to `owner`, since that is a likely and important mistake.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "notAssignable": {
                "summary": "Absent, empty, mis-cased or padded",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "role is required and must be exactly \"admin\", \"member\" or \"viewer\"",
                    "field": "role"
                  }
                }
              },
              "ownerRefused": {
                "summary": "Any casing of owner, e.g. \"owner\" or \"Owner\"",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "owner cannot be assigned; transferring ownership is not supported",
                    "field": "role"
                  }
                }
              },
              "badEmail": {
                "summary": "Invite only: the address is empty or has no @",
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "a valid email is required",
                    "field": "email"
                  }
                }
              }
            }
          }
        }
      },
      "SeatLimit": {
        "description": "The organization is out of seats. `seats` counts every `org_members` row INCLUDING the owner, and is measured against the ORGANIZATION OWNER's plan, not the caller's. The `free` tier allows 1 seat, so a free organization is single-person and its very first invite is refused here. Nothing was created; the existing roster is unchanged.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "error": {
                "code": "plan_limit",
                "message": "the free plan allows 1 seats per organization; upgrade to pro to raise this limit"
              }
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorEnvelope": {
        "type": "object",
        "title": "ErrorEnvelope",
        "description": "Body of every non-2xx response.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "$ref": "#/components/schemas/Error"
          }
        },
        "additionalProperties": false
      },
      "Error": {
        "type": "object",
        "title": "Error",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Stable machine-readable code. Maps one-to-one onto the HTTP status.",
            "enum": [
              "invalid_request",
              "unauthorized",
              "forbidden",
              "not_found",
              "conflict",
              "plan_limit",
              "internal"
            ]
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation. Safe to show to a user and to read back to a model."
          },
          "field": {
            "type": "string",
            "description": "The offending field, when the error is attributable to one."
          }
        },
        "additionalProperties": false
      },
      "Health": {
        "type": "object",
        "title": "Health",
        "required": [
          "status",
          "db"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ]
          },
          "db": {
            "type": "string",
            "enum": [
              "ok",
              "down"
            ]
          }
        }
      },
      "Plan": {
        "type": "string",
        "title": "Plan",
        "description": "The account's tier. Drives every limit in this API.",
        "enum": [
          "free",
          "pro",
          "team"
        ]
      },
      "Role": {
        "type": "string",
        "title": "Role",
        "description": "The caller's role in an organization. Ordered: viewer < member < admin < owner.",
        "enum": [
          "owner",
          "admin",
          "member",
          "viewer"
        ]
      },
      "Visibility": {
        "type": "string",
        "title": "Visibility",
        "description": "`public` exposes the chart through `/v1/public/{orgSlug}/{projectSlug}/gantt` with no authentication.",
        "enum": [
          "private",
          "public"
        ],
        "default": "private"
      },
      "DepType": {
        "type": "string",
        "title": "DepType",
        "description": "Finish-Start, Start-Start, Finish-Finish or Start-Finish.",
        "enum": [
          "FS",
          "SS",
          "FF",
          "SF"
        ],
        "default": "FS"
      },
      "User": {
        "type": "object",
        "title": "User",
        "required": [
          "id",
          "email",
          "name",
          "avatar_url",
          "plan"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string"
          },
          "avatar_url": {
            "type": "string"
          },
          "plan": {
            "$ref": "#/components/schemas/Plan"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the account was created."
          }
        }
      },
      "MeResponse": {
        "type": "object",
        "title": "MeResponse",
        "required": [
          "user",
          "orgs",
          "limits"
        ],
        "properties": {
          "user": {
            "$ref": "#/components/schemas/User"
          },
          "orgs": {
            "type": "array",
            "description": "Every organization the user belongs to, with their role on each.",
            "items": {
              "$ref": "#/components/schemas/Org"
            }
          },
          "limits": {
            "$ref": "#/components/schemas/Limits",
            "description": "The limits the caller's current plan buys. Keys are snake_case."
          }
        }
      },
      "Org": {
        "type": "object",
        "title": "Org",
        "required": [
          "id",
          "slug",
          "name",
          "owner_id",
          "role",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "slug": {
            "type": "string",
            "description": "Globally unique, URL-safe. Fixed at creation.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "minLength": 1,
            "maxLength": 64
          },
          "name": {
            "type": "string"
          },
          "owner_id": {
            "type": "string",
            "format": "uuid",
            "description": "The user whose plan governs this organization's project and task limits."
          },
          "role": {
            "$ref": "#/components/schemas/Role",
            "description": "The calling user's role in this organization."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Member": {
        "type": "object",
        "title": "Member",
        "required": [
          "user_id",
          "email",
          "name",
          "role"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string"
          },
          "role": {
            "$ref": "#/components/schemas/Role"
          }
        }
      },
      "OrgInput": {
        "type": "object",
        "title": "OrgInput",
        "required": [
          "name",
          "slug"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "slug": {
            "type": "string",
            "description": "Globally unique. `409 conflict` if taken.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "minLength": 1,
            "maxLength": 64
          }
        },
        "additionalProperties": false
      },
      "OrgPatch": {
        "type": "object",
        "title": "OrgPatch",
        "description": "Partial update. Only `name` is mutable.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          }
        },
        "additionalProperties": false
      },
      "OrgResponse": {
        "type": "object",
        "title": "OrgResponse",
        "required": [
          "org"
        ],
        "properties": {
          "org": {
            "$ref": "#/components/schemas/Org"
          }
        }
      },
      "OrgDetail": {
        "type": "object",
        "title": "OrgDetail",
        "required": [
          "org",
          "members"
        ],
        "properties": {
          "org": {
            "$ref": "#/components/schemas/Org"
          },
          "members": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Member"
            }
          }
        }
      },
      "OrgList": {
        "type": "object",
        "title": "OrgList",
        "required": [
          "orgs"
        ],
        "properties": {
          "orgs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Org"
            }
          }
        }
      },
      "Project": {
        "type": "object",
        "title": "Project",
        "required": [
          "id",
          "org_id",
          "org_slug",
          "slug",
          "name",
          "description",
          "visibility",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "org_id": {
            "type": "string",
            "format": "uuid"
          },
          "org_slug": {
            "type": "string",
            "description": "Denormalized so a client can build the public URL without a second call."
          },
          "slug": {
            "type": "string",
            "description": "Unique within the organization.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "minLength": 1,
            "maxLength": 64
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "visibility": {
            "$ref": "#/components/schemas/Visibility"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProjectInput": {
        "type": "object",
        "title": "ProjectInput",
        "required": [
          "name",
          "slug"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "slug": {
            "type": "string",
            "description": "Unique within the organization. `409 conflict` if taken.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "minLength": 1,
            "maxLength": 64
          },
          "description": {
            "type": "string",
            "default": ""
          },
          "visibility": {
            "$ref": "#/components/schemas/Visibility"
          }
        },
        "additionalProperties": false
      },
      "ProjectPatch": {
        "type": "object",
        "title": "ProjectPatch",
        "description": "Partial update. Send only the fields to change.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "slug": {
            "type": "string",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "minLength": 1,
            "maxLength": 64
          },
          "description": {
            "type": "string"
          },
          "visibility": {
            "$ref": "#/components/schemas/Visibility"
          }
        },
        "additionalProperties": false
      },
      "ProjectResponse": {
        "type": "object",
        "title": "ProjectResponse",
        "required": [
          "project"
        ],
        "properties": {
          "project": {
            "$ref": "#/components/schemas/Project"
          }
        }
      },
      "ProjectList": {
        "type": "object",
        "title": "ProjectList",
        "required": [
          "projects"
        ],
        "properties": {
          "projects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Project"
            }
          }
        }
      },
      "Task": {
        "type": "object",
        "title": "Task",
        "description": "A bar on the chart, as stored.",
        "required": [
          "id",
          "project_id",
          "name",
          "start_date",
          "end_date",
          "progress",
          "color",
          "assignee",
          "is_milestone",
          "sort_order"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "ref": {
            "type": [
              "string",
              "null"
            ],
            "description": "Caller-supplied stable key, unique per project. `null` when the task was created without one."
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Parent task, for a hierarchy. `null` at the top level. Deleting a parent deletes its children."
          },
          "name": {
            "type": "string"
          },
          "notes": {
            "type": "string"
          },
          "start_date": {
            "type": "string",
            "format": "date"
          },
          "end_date": {
            "type": "string",
            "format": "date",
            "description": "Inclusive, and never earlier than `start_date`. Equal to `start_date` for a milestone."
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "color": {
            "type": "string",
            "description": "CSS color for the bar, empty string for the theme default."
          },
          "assignee": {
            "type": "string",
            "description": "Free text; typically an email. Not a foreign key."
          },
          "is_milestone": {
            "type": "boolean"
          },
          "sort_order": {
            "type": "integer",
            "description": "Ascending display order within the project. Ties broken by name."
          }
        }
      },
      "TaskInput": {
        "type": "object",
        "title": "TaskInput",
        "description": "A task to create or update. Identify an existing task with `id`, or with `ref` to match on the caller-supplied key. With neither, a new task is inserted.",
        "required": [
          "name",
          "start_date",
          "end_date"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Update this exact task. Wins over `ref` when both are present."
          },
          "ref": {
            "type": "string",
            "description": "Stable key, unique per project. Used to match an existing task and as the target of `parent_ref`, `predecessor_ref` and `successor_ref` in the same request.",
            "minLength": 1,
            "maxLength": 128
          },
          "parent_ref": {
            "type": "string",
            "description": "Parent identified by `ref`, resolved against this request first and then against the project. Mutually exclusive with `parent_id`."
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Parent identified by ID. Send `null` to move the task to the top level."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500
          },
          "notes": {
            "type": "string",
            "default": ""
          },
          "start_date": {
            "type": "string",
            "format": "date",
            "examples": [
              "2026-09-01"
            ]
          },
          "end_date": {
            "type": "string",
            "format": "date",
            "description": "Must be greater than or equal to `start_date`, otherwise `400 invalid_request`.",
            "examples": [
              "2026-09-07"
            ]
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 0
          },
          "color": {
            "type": "string",
            "default": ""
          },
          "assignee": {
            "type": "string",
            "default": ""
          },
          "is_milestone": {
            "type": "boolean",
            "default": false,
            "description": "A milestone is drawn as a point. Set `start_date` equal to `end_date`."
          },
          "sort_order": {
            "type": "integer",
            "default": 0
          }
        },
        "additionalProperties": false
      },
      "TaskPatch": {
        "type": "object",
        "title": "TaskPatch",
        "description": "Partial `TaskInput`. Every field optional; omitted fields keep their stored value.",
        "properties": {
          "ref": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 128
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "parent_ref": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500
          },
          "notes": {
            "type": "string"
          },
          "start_date": {
            "type": "string",
            "format": "date"
          },
          "end_date": {
            "type": "string",
            "format": "date"
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "color": {
            "type": "string"
          },
          "assignee": {
            "type": "string"
          },
          "is_milestone": {
            "type": "boolean"
          },
          "sort_order": {
            "type": "integer"
          }
        },
        "additionalProperties": false
      },
      "TaskResponse": {
        "type": "object",
        "title": "TaskResponse",
        "required": [
          "task"
        ],
        "properties": {
          "task": {
            "$ref": "#/components/schemas/Task"
          }
        }
      },
      "Dependency": {
        "type": "object",
        "title": "Dependency",
        "description": "A directed edge from predecessor to successor.",
        "required": [
          "id",
          "project_id",
          "predecessor_id",
          "successor_id",
          "type",
          "lag_days"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "predecessor_id": {
            "type": "string",
            "format": "uuid"
          },
          "successor_id": {
            "type": "string",
            "format": "uuid",
            "description": "Never equal to `predecessor_id`."
          },
          "type": {
            "$ref": "#/components/schemas/DepType"
          },
          "lag_days": {
            "type": "integer",
            "description": "Days of lag; negative means lead."
          }
        }
      },
      "DepInput": {
        "type": "object",
        "title": "DepInput",
        "description": "Identify each end by exactly one of its `_id` or `_ref` form; both ends are required. Refs resolve against the same request first, then against tasks already in the project. An end that resolves to no task is `400 invalid_request`, not `404`, because it is a mistake in the payload.\n\nAn edge is unique per (`predecessor`, `successor`): sending one that already exists overwrites its `type` and `lag_days` rather than creating a duplicate. A self-edge, or an edge that would close a cycle, is `409 conflict`.",
        "properties": {
          "predecessor_id": {
            "type": "string",
            "format": "uuid"
          },
          "predecessor_ref": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128
          },
          "successor_id": {
            "type": "string",
            "format": "uuid"
          },
          "successor_ref": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128
          },
          "type": {
            "$ref": "#/components/schemas/DepType"
          },
          "lag_days": {
            "type": "integer",
            "default": 0
          }
        },
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "predecessor_id",
              "successor_id"
            ]
          },
          {
            "required": [
              "predecessor_id",
              "successor_ref"
            ]
          },
          {
            "required": [
              "predecessor_ref",
              "successor_id"
            ]
          },
          {
            "required": [
              "predecessor_ref",
              "successor_ref"
            ]
          }
        ]
      },
      "DependencyResponse": {
        "type": "object",
        "title": "DependencyResponse",
        "required": [
          "dependency"
        ],
        "properties": {
          "dependency": {
            "$ref": "#/components/schemas/Dependency"
          }
        }
      },
      "Gantt": {
        "type": "object",
        "title": "Gantt",
        "description": "A whole chart: the project plus every task and dependency in it. Returned by all three Gantt routes and by the public route.",
        "required": [
          "project",
          "tasks",
          "dependencies"
        ],
        "properties": {
          "project": {
            "$ref": "#/components/schemas/Project"
          },
          "tasks": {
            "type": "array",
            "description": "Ordered by `sort_order` ascending.",
            "items": {
              "$ref": "#/components/schemas/Task"
            }
          },
          "dependencies": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Dependency"
            }
          }
        }
      },
      "GanttInput": {
        "type": "object",
        "title": "GanttInput",
        "description": "Body of `PUT /v1/projects/{projectID}/gantt`: the complete intended state of the chart. Destructive in two ways at once. Tasks in the database and absent from `tasks` are DELETED, along with their children and any dependency touching them. Fields omitted on a task that IS present are RESET to their defaults. Dependencies are replaced wholesale.\n\nAny `project` key is accepted and ignored, so a document read from `GET /gantt` can be edited and sent straight back. Change project fields with `PATCH /v1/projects/{projectID}`.\n\nApplied in one transaction: if any task or dependency is rejected, nothing is written.",
        "required": [
          "tasks"
        ],
        "properties": {
          "project": {
            "type": "object",
            "description": "Ignored. Present so a document read from `GET /gantt` can be edited and sent straight back.",
            "additionalProperties": true
          },
          "tasks": {
            "type": "array",
            "description": "Every task the chart should contain afterwards, each described completely. An empty array deletes them all.",
            "items": {
              "$ref": "#/components/schemas/TaskReplaceInput"
            }
          },
          "dependencies": {
            "type": "array",
            "description": "Every dependency the chart should contain afterwards. Omitting this key, or sending an empty array, deletes every existing edge, so send the edges you want to keep.",
            "items": {
              "$ref": "#/components/schemas/DepInput"
            },
            "default": []
          }
        },
        "additionalProperties": false
      },
      "BatchInput": {
        "type": "object",
        "title": "BatchInput",
        "description": "Body of `POST /v1/projects/{projectID}/tasks:batch`: a set of changes to apply. Nothing is ever deleted and no field is ever reset, so it is safe to send only what is changing. Supply at least one task or one dependency; an empty body is `400 invalid_request`.\n\nApplied in one transaction: if any task or dependency is rejected, nothing is written.",
        "properties": {
          "tasks": {
            "type": "array",
            "description": "Tasks to insert or update. Matched by `id`, else by `ref`. Fields you omit keep their stored values.",
            "items": {
              "$ref": "#/components/schemas/TaskUpsertInput"
            },
            "default": []
          },
          "dependencies": {
            "type": "array",
            "description": "Dependencies to upsert. A new edge is inserted; an edge that already exists between the same two tasks has its `type` and `lag_days` overwritten by what you send. No edge is ever removed.",
            "items": {
              "$ref": "#/components/schemas/DepInput"
            },
            "default": []
          }
        },
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "tasks"
            ],
            "title": "tasks only"
          },
          {
            "required": [
              "dependencies"
            ],
            "title": "dependencies only"
          }
        ]
      },
      "APIKeyInput": {
        "type": "object",
        "title": "APIKeyInput",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Label so a human can tell keys apart.",
            "minLength": 1,
            "maxLength": 100
          }
        },
        "additionalProperties": false
      },
      "APIKeyCreated": {
        "type": "object",
        "title": "APIKeyCreated",
        "description": "`token` appears in this response only. It cannot be recovered later.",
        "required": [
          "id",
          "name",
          "prefix",
          "token"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string",
            "description": "First 12 characters of the token, which is `gt_live_` plus its first 4 characters. Used to identify a key after the token itself is no longer retrievable.",
            "examples": [
              "gt_live_BKW_"
            ]
          },
          "token": {
            "type": "string",
            "description": "The bearer token. Send as `Authorization: Bearer <token>`. Always 40 characters: the `gt_live_` prefix plus 32 characters of unpadded base64url, so it can contain `-` and `_` as well as letters and digits.",
            "pattern": "^gt_live_[A-Za-z0-9_-]{32}$",
            "examples": [
              "gt_live_BKW_7QmXbT2sVh0KpL9dRcAe4Nf1Jy-z"
            ]
          }
        }
      },
      "APIKey": {
        "type": "object",
        "title": "APIKey",
        "required": [
          "id",
          "name",
          "prefix",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string",
            "description": "First 12 characters of the token, which is `gt_live_` plus its first 4 characters. Used to identify a key after the token itself is no longer retrievable.",
            "examples": [
              "gt_live_BKW_"
            ]
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "`null` until the key authenticates a request."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "APIKeyList": {
        "type": "object",
        "title": "APIKeyList",
        "required": [
          "api_keys"
        ],
        "properties": {
          "api_keys": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/APIKey"
            }
          }
        }
      },
      "TaskReplaceInput": {
        "type": "object",
        "title": "TaskReplaceInput",
        "description": "A task inside `PUT /v1/projects/{projectID}/gantt`. This is a DECLARATIVE object: it must describe the task completely, because any field omitted here is reset to its column default on a task that already exists.\n\nFields reset by omission: `notes` to `\"\"`, `progress` to `0`, `color` to `\"\"`, `assignee` to `\"\"`, `is_milestone` to `false`, `sort_order` to `0`, `ref` to `null`, and the parent link to top level. `name`, `start_date` and `end_date` are never reset because they are required here.\n\nOmitting `ref` on a task matched by `id` erases that ref, losing the stable handle. Always send `ref` back. The safe pattern is to read the chart with `GET /gantt`, mutate the objects you got back, and send all of them.",
        "required": [
          "name",
          "start_date"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Update this exact task. Wins over `ref` when both are present."
          },
          "ref": {
            "type": [
              "string",
              "null"
            ],
            "description": "Stable key, unique per project. Send it on every task in a PUT: omitting it sets the stored ref to null.",
            "maxLength": 128
          },
          "parent_ref": {
            "type": "string",
            "description": "Parent identified by `ref`, resolved against this request first and then against the project. Omitting both parent fields moves the task to the top level."
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Parent identified by ID. `null`, or omitted, puts the task at the top level."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "description": "Required on every task in a PUT, whether it is new or already exists."
          },
          "notes": {
            "type": "string",
            "default": "",
            "description": "Omitted resets the stored value to an empty string."
          },
          "start_date": {
            "type": "string",
            "format": "date",
            "description": "Required on every task in a PUT.",
            "examples": [
              "2026-09-01"
            ]
          },
          "end_date": {
            "type": "string",
            "format": "date",
            "description": "Required on every task in a PUT, except when `is_milestone` is `true`, where it is filled in from `start_date`. Must be greater than or equal to `start_date`.",
            "examples": [
              "2026-09-07"
            ]
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 0,
            "description": "Omitted resets the stored value to 0."
          },
          "color": {
            "type": "string",
            "default": "",
            "description": "Omitted resets the stored value to an empty string."
          },
          "assignee": {
            "type": "string",
            "default": "",
            "description": "Omitted resets the stored value to an empty string."
          },
          "is_milestone": {
            "type": "boolean",
            "default": false,
            "description": "Omitted resets the stored value to false."
          },
          "sort_order": {
            "type": "integer",
            "default": 0,
            "description": "Omitted resets the stored value to 0."
          }
        },
        "additionalProperties": false
      },
      "TaskUpsertInput": {
        "type": "object",
        "title": "TaskUpsertInput",
        "description": "A task inside `POST /v1/projects/{projectID}/tasks:batch`. This is an INCREMENTAL object: send `id` or `ref` to identify an existing task plus only the fields you want to change. Everything you omit keeps its stored value, and the parent link is left alone.\n\n`name`, `start_date` and `end_date` are required only for a task that does not exist yet, that is, one whose `id` and `ref` match nothing in the project.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Update this exact task. Wins over `ref` when both are present."
          },
          "ref": {
            "type": "string",
            "description": "Stable key, unique per project. Matches an existing task, or names a new one, and is the target of `parent_ref`, `predecessor_ref` and `successor_ref` in the same request.",
            "minLength": 1,
            "maxLength": 128
          },
          "parent_ref": {
            "type": "string",
            "description": "Parent identified by `ref`. Omit to leave the existing parent alone."
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Parent identified by ID. Send `null` explicitly to move the task to the top level; omit to leave it alone."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "description": "Required only when the task is new. May not be an empty or blank string."
          },
          "notes": {
            "type": "string"
          },
          "start_date": {
            "type": "string",
            "format": "date",
            "description": "Required only when the task is new.",
            "examples": [
              "2026-09-26"
            ]
          },
          "end_date": {
            "type": "string",
            "format": "date",
            "description": "Required only when the task is new. Must be greater than or equal to `start_date`.",
            "examples": [
              "2026-09-29"
            ]
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "color": {
            "type": "string"
          },
          "assignee": {
            "type": "string"
          },
          "is_milestone": {
            "type": "boolean",
            "description": "A milestone is drawn as a point. When true, `end_date` may be omitted and is filled in from `start_date`."
          },
          "sort_order": {
            "type": "integer"
          }
        },
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "id"
            ],
            "title": "identify an existing task by id"
          },
          {
            "required": [
              "ref"
            ],
            "title": "identify by ref, or name a new task"
          },
          {
            "required": [
              "name",
              "start_date",
              "end_date"
            ],
            "title": "a new task with no ref"
          }
        ]
      },
      "Limits": {
        "type": "object",
        "title": "Limits",
        "description": "The quotas a plan buys. Every value is a count, except `-1`, which means unlimited and must not be read as zero.",
        "required": [
          "orgs",
          "projects_per_org",
          "tasks_per_project",
          "api_keys",
          "seats"
        ],
        "properties": {
          "orgs": {
            "type": "integer",
            "description": "Organizations this user may own. `-1` is unlimited.",
            "examples": [
              1,
              5,
              -1
            ]
          },
          "projects_per_org": {
            "type": "integer",
            "description": "Projects per organization, measured against the organization owner's plan. `-1` is unlimited.",
            "examples": [
              3,
              25,
              -1
            ]
          },
          "tasks_per_project": {
            "type": "integer",
            "description": "Tasks per project, measured against the organization owner's plan.",
            "examples": [
              50,
              1000,
              5000
            ]
          },
          "api_keys": {
            "type": "integer",
            "description": "Live API keys this user may hold.",
            "examples": [
              2,
              10,
              25
            ]
          },
          "seats": {
            "type": "integer",
            "description": "Members allowed in one organization, counting every `org_members` row including the owner. So `1` means a single-person org. Measured against the organization owner's plan. `-1` would be unlimited, though no current tier uses that here.",
            "examples": [
              1,
              3,
              25
            ]
          }
        }
      },
      "MemberRole": {
        "type": "string",
        "title": "MemberRole",
        "description": "A role assignable through the member routes. Matched EXACTLY: no case folding, no trimming, and no default. `\"Admin\"`, `\"member \"`, `\"MEMBER\"` and `\"\"` are all `400 invalid_request`.\n\nThis strictness is deliberate and must not be relaxed. This API is driven by AI agents, and quietly accepting a near-miss would silently grant a role the caller did not ask for, hiding a bug in the caller's prompt instead of reporting it. It is the same reason every request body rejects unknown fields rather than ignoring them. If you are tempted to add case-insensitive matching here, the correct fix is in the caller.\n\nNote the asymmetry with `email` on the invite route, which IS trimmed and lower-cased: an address is user-typed data, a role is a protocol token.\n\n`owner` is absent on purpose: `organizations.owner_id` is the source of truth for ownership, and transferring it is not implemented. Sending `owner` in any casing returns `400` with a message saying so.",
        "enum": [
          "admin",
          "member",
          "viewer"
        ]
      },
      "MemberInviteInput": {
        "type": "object",
        "title": "MemberInviteInput",
        "description": "Body of `POST /v1/orgs/{orgID}/members`. Identifies the person by email rather than by user id, so someone with no account yet can be added.\n\nIf no account owns the address, the membership attaches to a placeholder that the person claims when they first sign in with that address; their `name` defaults to the local part of the address. No mail is sent - tell them yourself.",
        "required": [
          "email",
          "role"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "minLength": 3,
            "description": "The invitee's address. Trimmed and lower-cased before use, unlike `role`. Must be non-empty and contain `@`, otherwise `400` with field `email`; no stricter validation than that.",
            "examples": [
              "invitee@example.test"
            ]
          },
          "role": {
            "$ref": "#/components/schemas/MemberRole"
          }
        },
        "additionalProperties": false
      },
      "MemberRoleInput": {
        "type": "object",
        "title": "MemberRoleInput",
        "description": "Body of `PATCH /v1/orgs/{orgID}/members/{userID}`. `role` is required; there is no partial-update behaviour to fall back on.",
        "required": [
          "role"
        ],
        "properties": {
          "role": {
            "$ref": "#/components/schemas/MemberRole"
          }
        },
        "additionalProperties": false
      },
      "MemberResponse": {
        "type": "object",
        "title": "MemberResponse",
        "required": [
          "member"
        ],
        "properties": {
          "member": {
            "$ref": "#/components/schemas/Member"
          }
        }
      }
    }
  }
}
