openapi: 3.1.0
info:
  title: ghostkey-server API
  version: 0.1.0
  description: >
    Machine-readable contract for the optional Ghostkey cloud sync server.
    The server owns this file; clients consume generated types/clients from it.


    **Rate limiting.** Every `/api/*` route except `/api/health` and
    `/api/stripe/webhook` is metered per client IP, and can answer `429` with
    `Retry-After` plus `RateLimit-Limit` / `-Remaining` / `-Reset`. Budgets
    differ by bucket: auth routes are tight, model and export routes are
    moderate, everything else (including the autosave hot path) is generous.
    `POST /api/auth/signin` carries a second, per-email budget spent only on
    failed attempts. A client that sees `429` should honour `Retry-After` rather
    than retry immediately. Because the limits are per IP, they are not a
    per-account quota. Only the `/api/auth/*` paths list `429` individually —
    there a client is expected to render it ("too many attempts, try again in
    N") — everywhere else it is cross-cutting and unlisted.


    **CORS.** Only browsers are affected. Requests without an `Origin` header —
    the Electron main process and React Native `fetch`, which is how both
    first-party clients talk to this server — are never origin-checked. A
    request that *does* carry an `Origin` must carry one on the server's
    allowlist (`ALLOWED_ORIGINS`, plus the server's own origin, plus any
    localhost port outside production) or it is rejected with `403`
    `origin_not_allowed` before the route runs. Credentialed requests are
    permitted, so the allowlist is exact-match and never `*`. The same check
    guards the `/ws` upgrade.


    **Security headers.** Every response carries `Content-Security-Policy`
    (`default-src 'none'` on API and `/openapi.yaml` responses),
    `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
    `Permissions-Policy`, and the `Cross-Origin-*` set. `Strict-Transport-Security`
    is added whenever the request reached the server over HTTPS.
servers:
  - url: http://localhost:3000
    description: Local development
security:
  - bearerAuth: []
tags:
  - name: Auth
  - name: Files
  - name: Folders
  - name: Projects
  - name: Documents
  - name: Assets
  - name: Illusion
  - name: Seance
  - name: Poltergeist
  - name: Chat
  - name: Export
  - name: Freewrite
  - name: Jobs
  - name: LLM
  - name: Realtime
  - name: Operations
paths:
  /api/auth/signup:
    post:
      tags: [Auth]
      operationId: signup
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SignupCredentials"
      responses:
        "200":
          description: Session created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthSession"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "409":
          description: Email is already registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                email_taken:
                  value: { error: email_taken }
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/auth/signin:
    post:
      tags: [Auth]
      operationId: signin
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuthCredentials"
      responses:
        "200":
          description: Session created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthSession"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          description: Invalid email or password.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                invalid_credentials:
                  value: { error: invalid_credentials }
        "429":
          description: >
            Too many signin attempts — either from this IP, or against this
            email address specifically. The per-email budget is spent only on
            failed attempts, so a correct password always clears it.
          headers:
            Retry-After:
              description: Seconds until another attempt is accepted.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                rate_limited:
                  value: { error: rate_limited }
  /api/auth/refresh:
    post:
      tags: [Auth]
      operationId: refreshSession
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token:
                  type: string
                  minLength: 1
      responses:
        "200":
          description: Session rotated. Store the returned refresh token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthSession"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          description: Refresh token is unknown, expired, revoked, or reused.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                invalid_refresh_token:
                  value: { error: invalid_refresh_token }
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/auth/signout:
    post:
      tags: [Auth]
      operationId: signout
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token:
                  type: string
                  minLength: 1
      responses:
        "204":
          description: Token revoked if known. Always no content.
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/health:
    get:
      tags: [Operations]
      operationId: getHealth
      security: []
      responses:
        "200":
          description: Server and realtime listener are healthy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: HTTP is available but realtime listener is degraded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
  /api/files:
    get:
      tags: [Files]
      operationId: listFiles
      parameters:
        - name: folder_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Omit to list files at root.
      responses:
        "200":
          description: Files in the selected folder.
          content:
            application/json:
              schema:
                type: object
                required: [files]
                properties:
                  files:
                    type: array
                    items:
                      $ref: "#/components/schemas/File"
        "400":
          $ref: "#/components/responses/InvalidFolder"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Files]
      operationId: uploadFile
      parameters:
        - name: name
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 255
        - name: folder_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "201":
          description: File row and bytes created atomically.
          content:
            application/json:
              schema:
                type: object
                required: [file]
                properties:
                  file:
                    $ref: "#/components/schemas/File"
        "400":
          description: Invalid name/folder, missing content type, or missing body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/NameTaken"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/files/{id}:
    parameters:
      - $ref: "#/components/parameters/FileId"
    get:
      tags: [Files]
      operationId: getFile
      responses:
        "200":
          description: File metadata.
          content:
            application/json:
              schema:
                type: object
                required: [file]
                properties:
                  file:
                    $ref: "#/components/schemas/File"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Files]
      operationId: updateFile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FilePatch"
      responses:
        "200":
          description: File renamed and/or moved.
          content:
            application/json:
              schema:
                type: object
                required: [file]
                properties:
                  file:
                    $ref: "#/components/schemas/File"
        "400":
          description: Invalid id, body, or folder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/NameTaken"
    delete:
      tags: [Files]
      operationId: deleteFile
      responses:
        "204":
          description: File soft-deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/files/{id}/blob:
    parameters:
      - $ref: "#/components/parameters/FileId"
    get:
      tags: [Files]
      operationId: downloadFile
      parameters:
        - $ref: "#/components/parameters/Range"
      responses:
        "200":
          description: Full file bytes.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "206":
          description: Partial file bytes.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: File bytes are not ready.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "410":
          $ref: "#/components/responses/Deleted"
        "502":
          $ref: "#/components/responses/UpstreamError"
    put:
      tags: [Files]
      operationId: replaceFileBlob
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: Bytes replaced and version incremented.
          content:
            application/json:
              schema:
                type: object
                required: [file]
                properties:
                  file:
                    $ref: "#/components/schemas/File"
        "400":
          description: Invalid id or missing body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Deleted"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/folders:
    get:
      tags: [Folders]
      operationId: listFolders
      responses:
        "200":
          description: Flat folder list.
          content:
            application/json:
              schema:
                type: object
                required: [folders]
                properties:
                  folders:
                    type: array
                    items:
                      $ref: "#/components/schemas/Folder"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Folders]
      operationId: createFolder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FolderCreate"
      responses:
        "201":
          description: Folder created.
          content:
            application/json:
              schema:
                type: object
                required: [folder]
                properties:
                  folder:
                    $ref: "#/components/schemas/Folder"
        "400":
          description: Invalid body or parent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/NameTaken"
  /api/folders/{id}:
    parameters:
      - $ref: "#/components/parameters/FolderId"
    patch:
      tags: [Folders]
      operationId: updateFolder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FolderPatch"
      responses:
        "200":
          description: Folder renamed and/or re-parented.
          content:
            application/json:
              schema:
                type: object
                required: [folder]
                properties:
                  folder:
                    $ref: "#/components/schemas/Folder"
        "400":
          description: Invalid id, body, or parent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/NameTaken"
    delete:
      tags: [Folders]
      operationId: deleteFolder
      responses:
        "204":
          description: Folder subtree and contained files soft-deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects:
    get:
      tags: [Projects]
      operationId: listProjects
      parameters:
        - name: folder_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Omit to list root projects.
      responses:
        "200":
          description: Lightweight project list.
          content:
            application/json:
              schema:
                type: object
                required: [projects]
                properties:
                  projects:
                    type: array
                    items:
                      $ref: "#/components/schemas/Project"
        "400":
          $ref: "#/components/responses/InvalidFolder"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Projects]
      operationId: createProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 255
                title:
                  type: string
                  maxLength: 255
                folder_id:
                  type: [string, "null"]
                  format: uuid
      responses:
        "201":
          description: Project created.
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "400":
          description: Invalid JSON, body, or folder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/NameTaken"
  /api/projects/{id}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProject
      responses:
        "200":
          description: Full project metadata with document and asset indexes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectDetail"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Projects]
      operationId: updateProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProjectPatch"
      responses:
        "200":
          description: Project updated.
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "400":
          description: Invalid id, JSON, body, folder, or structured manifest field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/NameTaken"
    delete:
      tags: [Projects]
      operationId: deleteProject
      responses:
        "204":
          description: Project, documents, and assets soft-deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/export/docx:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Export]
      operationId: exportProjectDocx
      summary: Assemble a Word (.docx) document from all chapter/note bodies.
      description: >
        Publish format — one-way, NOT a `.ghost` round-trip. Chapters render in
        reading order (the project's chapter_tree), each starting on a new page
        under a Heading 1; notes follow under a final "Notes" section unless
        `notes=false`. Markdown bold/italic/headings and smart typography are
        applied.
      parameters:
        - $ref: "#/components/parameters/IncludeNotes"
      responses:
        "200":
          description: Word document bytes.
          content:
            application/vnd.openxmlformats-officedocument.wordprocessingml.document:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/export/pdf:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Export]
      operationId: exportProjectPdf
      summary: Render all chapter/note bodies to PDF.
      description: >
        Publish format — one-way. The server renders the same HTML the desktop
        client prints, via headless Chromium. Chapters start on new pages; notes
        follow at the end unless `notes=false`.
      parameters:
        - $ref: "#/components/parameters/IncludeNotes"
      responses:
        "200":
          description: PDF bytes.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          description: Chromium failed to render the PDF (`pdf_render_failed`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/export/epub:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Export]
      operationId: exportProjectEpub
      summary: Assemble an EPUB from all chapter/note bodies plus the cover.
      description: >
        Publish format — one-way. Builds an EPUB 2.0.1 with a title page, an
        inline table of contents, one XHTML document per chapter/note (notes
        omitted when `notes=false`), and — if the project has a
        `cover_filename` resolving to an asset — the cover image (re-encoded
        to JPEG).
      parameters:
        - $ref: "#/components/parameters/IncludeNotes"
      responses:
        "200":
          description: EPUB bytes.
          content:
            application/epub+zip:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/export/txt:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Export]
      operationId: exportProjectText
      summary: Assemble a plain-text (.txt) file from all chapter/note bodies.
      description: >
        Publish format — one-way, NOT a `.ghost` round-trip. Chapters render in
        reading order (the project's chapter_tree) under their filename-stem
        title; notes follow under a final "NOTES" section unless `notes=false`.
        Markdown emphasis markers and heading hashes are stripped and smart
        typography is applied, leaving clean prose.
      parameters:
        - $ref: "#/components/parameters/IncludeNotes"
      responses:
        "200":
          description: Plain-text bytes.
          content:
            text/plain:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/manuscript:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectManuscript
      summary: Concatenate every chapter in reading order into one verbatim blob.
      description: >
        Verbatim full-manuscript text, the same assembly the /analyze route feeds
        the model: chapters in chapter_tree order, each under a `## filename`
        header, separated by `\n\n---\n\n`. Returned as plain text so a client
        (e.g. the PhantomMemory chat agent's read_novel tool) can fetch the whole
        novel in one request instead of N. Notes are NOT included.
      responses:
        "200":
          description: Verbatim manuscript text.
          content:
            text/plain:
              schema:
                type: string
        "400":
          description: Invalid id, or the project has no chapters (`no_chapters`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/import-docx:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Export]
      operationId: importProjectDocx
      summary: Bulk-create chapters from a parsed .docx into an existing project.
      description: >
        Additive import into an existing project — does NOT create a project or
        touch assets. The DOCX is split on heading levels into chapters (and
        optional one-level folders), exactly as the desktop client does. New
        chapters are appended to `chapter_tree` and `metadata_version` is bumped.
        Heading-derived filenames are de-duplicated against documents already in
        the project.
      requestBody:
        required: true
        content:
          application/vnd.openxmlformats-officedocument.wordprocessingml.document:
            schema:
              type: string
              format: binary
      responses:
        "201":
          description: The updated project plus the created document summaries.
          content:
            application/json:
              schema:
                type: object
                required: [project, documents]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
                  documents:
                    type: array
                    items:
                      $ref: "#/components/schemas/DocumentSummary"
        "400":
          description: Invalid id, empty body, or unreadable DOCX (`invalid_docx`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A concurrent import took one of the same filenames.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/thumbnail:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectThumbnail
      summary: Generate on demand or stream the cached cover thumbnail.
      responses:
        "200":
          description: WebP thumbnail bytes.
          headers:
            ETag:
              schema: { type: string }
            X-Thumbnail-Width:
              schema: { type: integer }
            X-Thumbnail-Height:
              schema: { type: integer }
          content:
            image/webp:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/projects/{id}/outline:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectOutline
      summary: Fetch the cached reverse outline, or null if none is cached.
      description: >
        Returns the cached reverse outline (per-chapter condensed reading)
        for the project, or `{ "outline": null }` when nothing is cached yet.
        The outline is generated client-side via `POST /api/projects/{id}/agent`
        and stored back with PUT; the server keeps it opaque.
        `format_version` lets clients refresh cached results after an outline
        format change. `central_relationship` names the relationship older
        outline formats tracked per chapter (null for current-format outlines,
        or when the book has none). Unlike `/continuity`, this row SURVIVES
        manuscript edits: entries carry per-chapter `doc_version` stamps
        (passthrough fields) and the outline pipeline diffs them against
        current document versions to regenerate only the chapters that
        changed.
      responses:
        "200":
          description: The cached outline, or null.
          content:
            application/json:
              schema:
                type: object
                required: [outline, central_relationship, format_version]
                properties:
                  outline:
                    nullable: true
                    type: array
                    items:
                      $ref: "#/components/schemas/ChapterOutline"
                  central_relationship:
                    nullable: true
                    type: string
                  format_version:
                    nullable: true
                    type: integer
                    description: Client-owned reverse-outline format version.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Projects]
      operationId: putProjectOutline
      summary: Store the client-generated reverse outline.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [outline]
              properties:
                outline:
                  type: array
                  items:
                    $ref: "#/components/schemas/ChapterOutline"
                central_relationship:
                  nullable: true
                  type: string
                  description: >
                    The relationship the per-chapter relational fields track
                    (the leads by name), or null when the book has none.
                format_version:
                  nullable: true
                  type: integer
                  description: Client-owned reverse-outline format version.
      responses:
        "200":
          description: The stored outline.
          content:
            application/json:
              schema:
                type: object
                required: [outline, central_relationship, format_version]
                properties:
                  outline:
                    type: array
                    items:
                      $ref: "#/components/schemas/ChapterOutline"
                  central_relationship:
                    nullable: true
                    type: string
                  format_version:
                    nullable: true
                    type: integer
                    description: Client-owned reverse-outline format version.
        "400":
          description: Invalid id, JSON, or outline body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/ai-outline:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectAiOutline
      summary: Fetch the cached AI-facing story outline, or null if none is cached.
      description: >
        Returns the cached AI-facing story outline for the project, or
        `{ "outline": null }` when nothing is cached yet. Same contract as
        `/outline` but an independent artifact: the author-facing reverse
        outline (read on the reverse-outline page) and the AI-facing story
        outline (injected into chat turns and used as the continuity check's
        designed-arc prior) are generated from different prompts and cached
        separately. Entries share the minimal `ChapterOutline` shape
        (`chapter` + `summary`, everything else client-owned passthrough).
        Like `/outline`, this row SURVIVES manuscript edits: entries carry
        per-chapter `doc_version` stamps (passthrough fields) and the outline
        pipeline reconciles incrementally, regenerating only the chapters
        that changed.
      responses:
        "200":
          description: The cached AI outline, or null.
          content:
            application/json:
              schema:
                type: object
                required: [outline, central_relationship, format_version]
                properties:
                  outline:
                    nullable: true
                    type: array
                    items:
                      $ref: "#/components/schemas/ChapterOutline"
                  central_relationship:
                    nullable: true
                    type: string
                  format_version:
                    nullable: true
                    type: integer
                    description: Client-owned AI-outline format version.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Projects]
      operationId: putProjectAiOutline
      summary: Store the client-generated AI-facing story outline.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [outline]
              properties:
                outline:
                  type: array
                  items:
                    $ref: "#/components/schemas/ChapterOutline"
                central_relationship:
                  nullable: true
                  type: string
                  description: >
                    The relationship the per-chapter relational fields track
                    (the leads by name), or null when the book has none.
                format_version:
                  nullable: true
                  type: integer
                  description: Client-owned AI-outline format version.
      responses:
        "200":
          description: The stored AI outline.
          content:
            application/json:
              schema:
                type: object
                required: [outline, central_relationship, format_version]
                properties:
                  outline:
                    type: array
                    items:
                      $ref: "#/components/schemas/ChapterOutline"
                  central_relationship:
                    nullable: true
                    type: string
                  format_version:
                    nullable: true
                    type: integer
                    description: Client-owned AI-outline format version.
        "400":
          description: Invalid id, JSON, or outline body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/continuity:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectContinuity
      summary: Fetch the cached continuity report, or null if none is cached.
      description: >
        Returns the cached continuity report for the project, or
        `{ "report": null }` when nothing is cached yet. The report is
        generated client-side (a three-pass check via
        `POST /api/projects/{id}/agent`) and stored back with PUT; the server
        keeps it opaque. The cache is invalidated (dropped) whenever the
        manuscript meaningfully changes — a new chapter draft snapshot, a
        chapter delete, a chapter reorder, or a DOCX import. It is the only
        derived-artifact cache still dropped on edit; both outline rows
        survive and reconcile incrementally.
      responses:
        "200":
          description: The cached report, or null.
          content:
            application/json:
              schema:
                type: object
                required: [report]
                properties:
                  report:
                    nullable: true
                    allOf:
                      - $ref: "#/components/schemas/ContinuityReport"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Projects]
      operationId: putProjectContinuity
      summary: Store the client-generated continuity report.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [report]
              properties:
                report:
                  $ref: "#/components/schemas/ContinuityReport"
      responses:
        "200":
          description: The stored report.
          content:
            application/json:
              schema:
                type: object
                required: [report]
                properties:
                  report:
                    $ref: "#/components/schemas/ContinuityReport"
        "400":
          description: Invalid id, JSON, or report body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/bible:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectBible
      summary: Fetch the world bible, or null if the world_bible job never ran.
      description: >
        The manuscript-derived world bible: entity cards (characters, places,
        terms) produced by the server-side `world_bible` job's single
        whole-manuscript extraction pass, with the author's overrides
        applied. `spellings` is the flat canonical-spellings list derived
        from the visible cards' names and aliases — typo-marked forms
        excluded — ranked characters → places → terms by mention count,
        capped at 200; the same list the transcribe/OCR proxies inject;
        clients must not re-derive it. The row survives manuscript edits (a
        chapter fingerprint makes a nothing-changed run a cache hit), and
        overrides survive regeneration. Distinct from the chat memory's
        `novel_info` story bible.
      responses:
        "200":
          description: The bible and derived spellings, or a null bible.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BibleResponse"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/projects/{id}/bible/overrides:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    put:
      tags: [Projects]
      operationId: putProjectBibleOverrides
      summary: Replace the author's bible overrides and re-merge.
      description: >
        Full-replace, last-write-wins. The server re-derives the cards from
        the stored whole-manuscript extraction under the new overrides (no
        model call) and returns the same shape as GET /bible so the client
        can cache-swap without a second fetch. Overrides attach to a card
        when the override `key` matches any of the card's name/alias forms
        (case- and diacritic-insensitive); typo marks match by exact form;
        unmatched overrides are kept dormant and reattach if a later
        regeneration re-produces the entity. 404 `no_bible` also covers a
        stored bible from an older format — regenerate first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [overrides]
              properties:
                overrides:
                  $ref: "#/components/schemas/BibleOverrides"
      responses:
        "200":
          description: The re-merged bible and derived spellings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BibleResponse"
        "400":
          description: Invalid id, JSON, or overrides body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No bible generated yet (`no_bible`) — nothing to override.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/book-map:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectBookMap
      summary: Fetch the book map, or null if the book_map job never ran.
      description: >
        The arc-first book map: the story's main arc (the character arc —
        the plotline whose resolution requires growth) plus its acts, each
        carrying five structural elements, the external plot's per-act
        pressure, and its scene list. Generated by the server-side
        `book_map` job from the cached AI story outline — never from the
        raw manuscript — so the external threat plot reads as pressure on
        the arc instead of being mapped as the story. Server-generated
        only; there is no client PUT. The row survives manuscript edits (a
        chapter fingerprint makes a nothing-changed run a cache hit).
      responses:
        "200":
          description: The book map, or null.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookMapResponse"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/projects/{id}/jobs:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Jobs]
      operationId: startJob
      summary: Start a server-side pipeline job (outline / continuity).
      description: >
        Starts one of the long-running LLM pipelines as a server-side job and
        returns 202 with its snapshot immediately — the pipeline runs
        detached and narrates through the job row. Watch it over `/ws`
        (`entity: "job"` events; re-fetch on each) or by polling the jobs
        routes. Results land in the existing artifact caches
        (`/outline`, `/ai-outline`, `/continuity`, `/bible`, `/book-map`) —
        fetch those once the job is `done`. One running job per (project, kind): a duplicate start
        gets 409 `job_running`. A running row whose machine has not
        heartbeat for 5 minutes is presumed dead and is superseded (marked
        `error: interrupted`) by the next start. `force` skips the artifact
        cache and regenerates.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobStartRequest"
      responses:
        "202":
          description: The job was started (or attached to a warm cache).
          content:
            application/json:
              schema:
                type: object
                required: [job]
                properties:
                  job:
                    $ref: "#/components/schemas/JobSnapshot"
        "400":
          description: >
            Invalid id, JSON, or `kind` (`invalid_id`, `invalid_json`,
            `invalid_kind`), or the project has no chapters (`no_chapters`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A job of this kind is already running (`job_running`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
    get:
      tags: [Jobs]
      operationId: listJobs
      summary: List the project's jobs (running + retained), with rehydrated questions.
      description: >
        Every job the panel should know about — running rows plus retained
        finished ones (errors stay visible; done rows stay until dismissed),
        newest first. Stale running rows (no heartbeat for 5 minutes) are
        repaired to `error: interrupted` on read. When no continuity job row
        exists but the cached continuity report still holds
        verified-unresolved hard errors, a synthetic `done` snapshot with id
        `cq-<projectId>` carries their unanswered questions; answer or
        re-run to clear it (dismissing it is a no-op and it returns on the
        next read).
      responses:
        "200":
          description: The project's job snapshots.
          content:
            application/json:
              schema:
                type: object
                required: [jobs]
                properties:
                  jobs:
                    type: array
                    items:
                      $ref: "#/components/schemas/JobSnapshot"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/jobs/{jobId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: jobId
        in: path
        required: true
        schema:
          type: string
        description: >
          A job row uuid, or the synthetic `cq-<projectId>` rehydration id
          (valid for DELETE and the answer route, not GET).
    get:
      tags: [Jobs]
      operationId: getJob
      summary: Fetch one job's snapshot.
      description: >
        The re-fetch target for a `/ws` `entity: "job"` event. Synthetic
        `cq-*` ids have no row — re-fetch the list instead.
      responses:
        "200":
          description: The job snapshot.
          content:
            application/json:
              schema:
                type: object
                required: [job]
                properties:
                  job:
                    $ref: "#/components/schemas/JobSnapshot"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown job (`job_not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
    delete:
      tags: [Jobs]
      operationId: cancelOrDismissJob
      summary: Cancel a running job, or dismiss a finished one.
      description: >
        A running job gets `cancel_requested` set and 202
        `{ "cancelling": true }` — the pipeline aborts at its next
        between-model-calls check and the row flips to `cancelled` (nothing
        is cached). A finished job's row is deleted (204). Dismissing the
        synthetic `cq-*` rehydration card is a 204 no-op; its questions
        return on the next list read until answered.
      responses:
        "202":
          description: Cancellation requested.
          content:
            application/json:
              schema:
                type: object
                required: [cancelling]
                properties:
                  cancelling:
                    type: boolean
                    const: true
        "204":
          description: The finished job's card was dismissed.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown job (`job_not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/jobs/{jobId}/answer:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: jobId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [Jobs]
      operationId: answerJobQuestion
      summary: Answer a job's author question.
      description: >
        Applies the author's answer to a job question (today: continuity's
        "which is the story?"). `current` and `intentional` answers append a
        canon/intent bullet to the story bible
        (`chat_memories.novel_info`); the resolution is persisted on the job
        row and stamped into the cached continuity report so the question
        stops rehydrating. Valid against a live job row, a retained one, or
        the synthetic `cq-<projectId>` snapshot (resolved against the cached
        report by the stable question id).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobAnswerRequest"
      responses:
        "200":
          description: The answer was applied.
          content:
            application/json:
              schema:
                type: object
                required: [resolution]
                properties:
                  resolution:
                    type: object
                    description: How the finding was resolved (pipeline-owned shape).
                    additionalProperties: true
        "400":
          description: >
            Invalid id, JSON, body, or option (`invalid_id`, `invalid_json`,
            `invalid_body`, `invalid_answer`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown job or question (`job_not_found`, `question_not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/documents:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Documents]
      operationId: createDocument
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, filename]
              properties:
                kind:
                  $ref: "#/components/schemas/DocumentKind"
                filename:
                  type: string
                  minLength: 1
                  maxLength: 255
                content:
                  type: string
      responses:
        "201":
          description: Document created.
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document:
                    $ref: "#/components/schemas/Document"
        "400":
          description: Invalid id, JSON, body, filename, kind, or content size.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/FilenameTaken"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/documents/{did}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/DocumentId"
    get:
      tags: [Documents]
      operationId: getDocument
      responses:
        "200":
          description: Document content and metadata.
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document:
                    $ref: "#/components/schemas/Document"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Documents]
      operationId: putDocument
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
      responses:
        "200":
          description: Document overwritten; version incremented by the server.
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document:
                    $ref: "#/components/schemas/Document"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
    patch:
      tags: [Documents]
      operationId: renameDocument
      summary: Rename a document (change its filename display title).
      description: >
        Updates the document's `filename` in place and bumps its version.
        A document's identity is its `id`; filenames are duplicatable display
        titles, so a rename never collides and preserves the id and version
        history (unlike a delete + re-create).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename]
              properties:
                filename:
                  type: string
      responses:
        "200":
          description: Document renamed; version incremented by the server.
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document:
                    $ref: "#/components/schemas/Document"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Documents]
      operationId: deleteDocument
      responses:
        "204":
          description: Document soft-deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/documents/{did}/versions:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/DocumentId"
    get:
      tags: [Documents]
      operationId: listChapterVersions
      summary: List saved chapter snapshots, newest first.
      responses:
        "200":
          description: Saved snapshots. Notes return not_found.
          content:
            application/json:
              schema:
                type: object
                required: [versions]
                properties:
                  versions:
                    type: array
                    items:
                      $ref: "#/components/schemas/ChapterVersion"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/assets:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Assets]
      operationId: uploadProjectAsset
      parameters:
        - name: filename
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 255
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "201":
          description: Asset uploaded.
          content:
            application/json:
              schema:
                type: object
                required: [asset]
                properties:
                  asset:
                    $ref: "#/components/schemas/Asset"
        "400":
          description: Invalid id, filename, content type, or missing body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/FilenameTaken"
        "413":
          description: Asset exceeded 25 MB.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/projects/{id}/assets/{aid}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/AssetId"
    get:
      tags: [Assets]
      operationId: downloadProjectAsset
      parameters:
        - $ref: "#/components/parameters/Range"
      responses:
        "200":
          description: Full asset bytes.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "206":
          description: Partial asset bytes.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Deleted"
        "502":
          $ref: "#/components/responses/UpstreamError"
    delete:
      tags: [Assets]
      operationId: deleteProjectAsset
      responses:
        "204":
          description: Asset soft-deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/agent:
    post:
      tags: [LLM]
      operationId: runAgent
      summary: Unified project-scoped LLM agent (single SSE stream)
      description: >
        The single LLM-execution endpoint. The client supplies a raw system +
        prompt (it owns intent and prompt authoring) plus optional project-context
        entries to fetch (manuscript, notes, a specific file) and/or inline text
        blocks. The server resolves the context deterministically, assembles the
        full user prompt (project header + context sections + the client's
        prompt), and streams the result as one SSE stream whose events are tagged
        by `source`. `sources` (a single model id) overrides the default Gemini
        stream: that one model is streamed directly, tagged as the `default`
        source (the chat model picker). The terminal sentinel is `data: [DONE]`.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AgentRequest"
      responses:
        "200":
          description: >
            Server-Sent Events. Each data event is a JSON object tagged by
            `source`: {"source":"default","text":"…"} / {"source":"default","done":true}
            / {"source":"default","error":"…"} (mid-stream upstream failure,
            e.g. a model refusal). On the native tool-calling path (`messages`
            present) the turn additionally ends with
            {"source":"default","message":{"content":[…],"stop_reason":"…"}} —
            the raw assistant content blocks to echo verbatim on the next
            request, with stop_reason "tool_use" meaning the client should run
            the requested tools and continue. The terminal sentinel is
            data: [DONE].
          content:
            text/event-stream:
              schema:
                type: string
        "400":
          description: >
            Malformed body (`invalid_body`/`invalid_context`/`invalid_inline`),
            missing prompt, an unknown model id or more than one `sources` entry
            (`invalid_sources`), or the project has no chapters when one was
            requested (`no_chapters`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/projects/{id}/semantic-search:
    post:
      tags: [LLM]
      operationId: semanticSearch
      summary: Embedding-based semantic search over a project's chapters
      description: >
        Searches the manuscript by meaning rather than exact wording (the
        PhantomMemory chat `semantic_search` tool). Chapters are chunked on
        paragraph boundaries and embedded with the server-held Gemini key; the
        index is derived data, rebuilt lazily on this path whenever a chapter's
        version no longer matches its indexed rows — so the first search after
        heavy edits (or an import) is slow, subsequent searches are fast.
        Returns the top-K most similar chunks by cosine similarity.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SemanticSearchRequest"
      responses:
        "200":
          description: The top-K most similar chapter chunks, best first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SemanticSearchResponse"
        "400":
          description: >
            Malformed body (`invalid_body`/`invalid_query`/`invalid_top_k`/
            `invalid_chapter`), or the project has no chapters (`no_chapters`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/transcribe:
    post:
      tags: [LLM]
      operationId: transcribeAudio
      summary: Speech-to-text for a single audio chunk (server-held ElevenLabs key).
      description: >
        The client records, meters, and splits audio into chunks locally, then
        POSTs each finished chunk here as multipart/form-data with a single
        `file` field. The server forwards it to ElevenLabs with the server-held
        key, then runs a best-effort, manuscript-safe Gemini Flash cleanup pass
        over the transcript: the model's output is diffed against the raw
        transcript and only small mechanical word-level fixes are merged (a
        rewrite is dropped by construction; falls back to the raw transcript
        on any failure). Clients insert the returned text as-is — there is no
        client-side cleanup pass. Neither provider key leaves the server. Chunks
        larger than 25 MB are rejected with 413. When `project_id` names a
        project the caller owns, the cleanup pass also receives the project's
        world-bible spellings (GET /bible) so dictated names come back in
        their canonical form — best-effort: an absent, invalid, or foreign id
        just means no spellings, never an error.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: One recorded audio chunk (e.g. audio/m4a).
                project_id:
                  type: string
                  format: uuid
                  description: >
                    Optional. Inject this project's world-bible spellings
                    into the cleanup pass.
      responses:
        "200":
          description: Transcript of the chunk (may be empty for silence).
          content:
            application/json:
              schema:
                type: object
                required: [text]
                properties:
                  text:
                    type: string
        "400":
          description: Missing or empty file field, or unparseable body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/ocr:
    post:
      tags: [LLM]
      operationId: ocrImage
      summary: Photo-to-text OCR for a single image (server-held Anthropic key).
      description: >
        The client captures and base64-encodes a photo locally, then POSTs it
        here. The server forwards it to Claude vision with the server-held key
        and returns the transcript. The Anthropic key never leaves the server.
        Images whose base64 payload exceeds ~12 MB are rejected with 413.
        When `project_id` names a project the caller owns, the vision call
        also receives the project's world-bible spellings (GET /bible) so
        handwritten names transcribe canonically — best-effort: an absent,
        invalid, or foreign id just means no spellings, never an error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_base64, media_type]
              properties:
                image_base64:
                  type: string
                  description: "Base64-encoded image bytes (no data: URI prefix)."
                media_type:
                  type: string
                  enum: [image/jpeg, image/png, image/gif, image/webp]
                project_id:
                  type: string
                  format: uuid
                  description: >
                    Optional. Inject this project's world-bible spellings
                    into the vision prompt.
      responses:
        "200":
          description: Transcribed text from the image.
          content:
            application/json:
              schema:
                type: object
                required: [text]
                properties:
                  text:
                    type: string
        "400":
          description: Missing image, or unsupported media_type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/projects/{id}/illusion/templates:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Illusion]
      operationId: listIllusionTemplates
      summary: >
        List stored page-type template overrides. A kind with no row means
        "pure client defaults"; an empty list is the initial state.
      responses:
        "200":
          description: Stored template overrides.
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items:
                      $ref: "#/components/schemas/IllusionTemplate"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/illusion/templates/{kind}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/IllusionTemplateKind"
    put:
      tags: [Illusion]
      operationId: putIllusionTemplate
      summary: Upsert a template's style overrides; version bumps on update.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [styles]
              properties:
                styles:
                  $ref: "#/components/schemas/IllusionStyles"
      responses:
        "200":
          description: Template upserted.
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template:
                    $ref: "#/components/schemas/IllusionTemplate"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
    delete:
      tags: [Illusion]
      operationId: deleteIllusionTemplate
      summary: Reset the template to client defaults (delete its override row).
      responses:
        "204":
          description: Template reset (idempotent).
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/illusion/page-styles:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Illusion]
      operationId: listIllusionPageStyles
      summary: >
        List per-page style overrides. Anchored chapter-relative:
        (document_id, page_index within that chapter); a null document_id
        anchors front-matter pages.
      responses:
        "200":
          description: Stored per-page overrides.
          content:
            application/json:
              schema:
                type: object
                required: [page_styles]
                properties:
                  page_styles:
                    type: array
                    items:
                      $ref: "#/components/schemas/IllusionPageStyle"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Illusion]
      operationId: putIllusionPageStyle
      summary: >
        Upsert a per-page override on the (document_id, page_index) anchor.
        A non-null document_id must be a document of this project.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_id, page_index, styles]
              properties:
                document_id:
                  type: [string, "null"]
                  format: uuid
                page_index:
                  type: integer
                  minimum: 0
                styles:
                  $ref: "#/components/schemas/IllusionStyles"
      responses:
        "200":
          description: Page style upserted.
          content:
            application/json:
              schema:
                type: object
                required: [page_style]
                properties:
                  page_style:
                    $ref: "#/components/schemas/IllusionPageStyle"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/illusion/page-styles/{psid}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/PageStyleId"
    delete:
      tags: [Illusion]
      operationId: deleteIllusionPageStyle
      summary: Remove a per-page override (page falls back to its template).
      responses:
        "204":
          description: Page style removed.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/seance/boards:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Seance]
      operationId: listSeanceBoards
      summary: >
        List the project's Séance boards as summaries (blocks omitted),
        ordered by the client-managed sort_index.
      responses:
        "200":
          description: The project's boards.
          content:
            application/json:
              schema:
                type: object
                required: [boards]
                properties:
                  boards:
                    type: array
                    items:
                      $ref: "#/components/schemas/SeanceBoardSummary"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Seance]
      operationId: createSeanceBoard
      summary: Create an empty board. Name must be unique within the project.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                sort_index:
                  type: integer
      responses:
        "201":
          description: Board created.
          content:
            application/json:
              schema:
                type: object
                required: [board]
                properties:
                  board:
                    $ref: "#/components/schemas/SeanceBoard"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/NameTaken"
  /api/projects/{id}/seance/boards/{bid}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/BoardId"
    get:
      tags: [Seance]
      operationId: getSeanceBoard
      summary: Read a single board with its full blocks array.
      responses:
        "200":
          description: The board.
          content:
            application/json:
              schema:
                type: object
                required: [board]
                properties:
                  board:
                    $ref: "#/components/schemas/SeanceBoard"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Seance]
      operationId: patchSeanceBoard
      summary: >
        Save blocks (autosave hot path), rename, and/or reorder a board.
        Any subset of fields may be present; version bumps on every write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                blocks:
                  $ref: "#/components/schemas/SeanceBlocks"
                name:
                  type: string
                sort_index:
                  type: integer
      responses:
        "200":
          description: Board updated.
          content:
            application/json:
              schema:
                type: object
                required: [board]
                properties:
                  board:
                    $ref: "#/components/schemas/SeanceBoard"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/NameTaken"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
    delete:
      tags: [Seance]
      operationId: deleteSeanceBoard
      summary: Soft-delete a board.
      responses:
        "204":
          description: Board deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/plan:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Poltergeist]
      operationId: getProjectPlan
      summary: Fetch the authored outline plan, or null if none exists.
      description: >
        Returns the project's authored outline plan — the Poltergeist board —
        or `{ "plan": null }` when the writer hasn't created one yet. Unlike
        the generated `/outline` and `/ai-outline` caches this is a hand-edited
        document: the client seeds it from the reverse outline and the writer
        owns it from then on. The server keeps it opaque and never invalidates
        it; the client's compare pass reconciles it against the manuscript on
        demand.
      responses:
        "200":
          description: The stored plan, or null.
          content:
            application/json:
              schema:
                type: object
                required: [plan]
                properties:
                  plan:
                    nullable: true
                    $ref: "#/components/schemas/ProjectPlan"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Poltergeist]
      operationId: putProjectPlan
      summary: Store the authored outline plan.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan:
                  $ref: "#/components/schemas/ProjectPlan"
      responses:
        "200":
          description: The stored plan.
          content:
            application/json:
              schema:
                type: object
                required: [plan]
                properties:
                  plan:
                    $ref: "#/components/schemas/ProjectPlan"
        "400":
          description: Invalid id, JSON, or plan body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: Plan JSON exceeds the 1 MiB cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/tasks:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Poltergeist]
      operationId: getProjectTasks
      summary: Fetch the manual task list, or null if none exists.
      description: >
        Returns the project's manual task list — Poltergeist's to-dos — or
        `{ "tasks": null }` when the writer hasn't created one yet. Purely
        hand-written: tasks are created and completed by the writer, with no
        generation or automation behind them. The server keeps the blob
        opaque (same contract as `/plan`).
      responses:
        "200":
          description: The stored task list, or null.
          content:
            application/json:
              schema:
                type: object
                required: [tasks]
                properties:
                  tasks:
                    nullable: true
                    $ref: "#/components/schemas/ProjectTasks"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Poltergeist]
      operationId: addProjectTask
      summary: Add one task to the top of the list.
      description: >
        Prepends a single task (newest first, matching the list's display
        order), creating the list (with the given client-owned
        `format_version`) on the writer's first-ever task; an existing list
        keeps its stored version. Ids are client-minted, so a replayed add
        whose response was lost replaces the matching task instead of
        duplicating it. Task ops are read-modify-write under a project row
        lock, so two clients editing different tasks never clobber each
        other.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format_version, task]
              properties:
                format_version:
                  type: integer
                  description: >
                    Client-owned tasks format version; used only when this
                    call creates the list.
                task:
                  $ref: "#/components/schemas/ProjectTaskItem"
      responses:
        "200":
          description: The updated task list.
          content:
            application/json:
              schema:
                type: object
                required: [tasks]
                properties:
                  tasks:
                    $ref: "#/components/schemas/ProjectTasks"
        "400":
          description: Invalid id, JSON, or task body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: Tasks JSON exceeds the 256 KiB cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/tasks/{taskId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: taskId
        in: path
        required: true
        schema: { type: string }
        description: The client-minted task id.
    patch:
      tags: [Poltergeist]
      operationId: updateProjectTask
      summary: Replace one task in place.
      description: >
        Replaces the task with the matching id (the path id wins over any id
        in the body). Read-modify-write under a project row lock, like the
        add. A task that no longer exists is 404 `task_not_found` — the edit
        has nothing to land on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task]
              properties:
                task:
                  $ref: "#/components/schemas/ProjectTaskItem"
      responses:
        "200":
          description: The updated task list.
          content:
            application/json:
              schema:
                type: object
                required: [tasks]
                properties:
                  tasks:
                    $ref: "#/components/schemas/ProjectTasks"
        "400":
          description: Invalid id, JSON, or task body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown project, or no task with that id (`task_not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "413":
          description: Tasks JSON exceeds the 256 KiB cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
    delete:
      tags: [Poltergeist]
      operationId: deleteProjectTask
      summary: Remove one task.
      description: >
        Removes the task with the matching id. Idempotent: a task that's
        already gone (or a list that never existed) is still 200, so a client
        replaying a remove whose response was lost never errors.
      responses:
        "200":
          description: >
            The updated task list, or null when the list never existed.
          content:
            application/json:
              schema:
                type: object
                required: [tasks]
                properties:
                  tasks:
                    nullable: true
                    $ref: "#/components/schemas/ProjectTasks"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/word-stats:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Poltergeist]
      operationId: getProjectWordStats
      summary: >
        The daily words series behind Poltergeist's tracker. Returns one entry
        per local calendar day, oldest first, with days of no writing filled in
        rather than omitted.
      parameters:
        - name: days
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 365, default: 30 }
        - name: tz_offset
          in: query
          required: false
          description: >
            Minutes east of UTC (JS: -new Date().getTimezoneOffset()). The
            client supplies its own offset because a writing day ends at the
            author's midnight, not the server's. Defaults to 0 (UTC).
          schema: { type: integer, minimum: -1440, maximum: 1440, default: 0 }
      responses:
        "200":
          description: The daily series.
          content:
            application/json:
              schema:
                type: object
                required: [days]
                properties:
                  days:
                    type: array
                    items:
                      $ref: "#/components/schemas/WordStatsDay"
        "400":
          description: Non-UUID project id, or `invalid_query` for an out-of-range parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/chat/sessions:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Chat]
      operationId: listChatSessions
      summary: >
        List the project's saved PhantomMemory Chat sessions as summaries
        (messages omitted), most-recently-updated first.
      responses:
        "200":
          description: The project's chat sessions.
          content:
            application/json:
              schema:
                type: object
                required: [sessions]
                properties:
                  sessions:
                    type: array
                    items:
                      $ref: "#/components/schemas/ChatSessionSummary"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Chat]
      operationId: createChatSession
      summary: >
        Create a chat session. `messages` is optional; a session may be
        created empty and filled by later PATCHes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                messages:
                  $ref: "#/components/schemas/ChatSessionMessages"
      responses:
        "201":
          description: Session created.
          content:
            application/json:
              schema:
                type: object
                required: [session]
                properties:
                  session:
                    $ref: "#/components/schemas/ChatSession"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/chat/sessions/{sid}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/SessionId"
    get:
      tags: [Chat]
      operationId: getChatSession
      summary: Read a single chat session with its full messages transcript.
      responses:
        "200":
          description: The session.
          content:
            application/json:
              schema:
                type: object
                required: [session]
                properties:
                  session:
                    $ref: "#/components/schemas/ChatSession"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Chat]
      operationId: patchChatSession
      summary: >
        Save messages (autosave hot path) and/or rename a session. Any
        subset of fields may be present; version bumps on every write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                messages:
                  $ref: "#/components/schemas/ChatSessionMessages"
                title:
                  type: string
      responses:
        "200":
          description: Session updated.
          content:
            application/json:
              schema:
                type: object
                required: [session]
                properties:
                  session:
                    $ref: "#/components/schemas/ChatSession"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
    delete:
      tags: [Chat]
      operationId: deleteChatSession
      summary: Soft-delete a chat session.
      responses:
        "204":
          description: Session deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/memory:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Chat]
      operationId: getChatMemory
      summary: >
        Read the PhantomMemory chat's long-term memory document. `memory`
        is null until the first reflection (or a manual PUT) writes one.
      responses:
        "200":
          description: The project's chat memory.
          content:
            application/json:
              schema:
                type: object
                required: [memory]
                properties:
                  memory:
                    anyOf:
                      - $ref: "#/components/schemas/ChatMemory"
                      - type: "null"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Chat]
      operationId: putChatMemory
      summary: >
        Manually overwrite the memory document (author edit). Does not
        advance the reflection high-water mark: the next dream still reads
        every session since the last reflection, using the edited text as
        its base. Version bumps on every write.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [memory]
              properties:
                memory:
                  type: string
                  description: Non-empty markdown; capped at 32 KiB.
                novel_info:
                  type: [string, "null"]
                  description: >
                    Optional story bible. A string sets it (capped at 16 KiB),
                    null clears it, and omitting the field leaves the stored
                    value untouched.
      responses:
        "200":
          description: Memory saved.
          content:
            application/json:
              schema:
                type: object
                required: [memory]
                properties:
                  memory:
                    $ref: "#/components/schemas/ChatMemory"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
    delete:
      tags: [Chat]
      operationId: deleteChatMemory
      summary: >
        Forget everything: hard-delete the memory row (it is derived data,
        regenerable from the saved sessions) and reset the reflection
        high-water mark so the next dream re-reads all sessions. Idempotent —
        deleting an absent memory still returns 204. Note this is
        project-wide, not chat-scoped: it also drops `style_sheet`, so the
        line editor forgets the author's conventions too.
      responses:
        "204":
          description: Memory cleared.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/memory/style-sheet:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    put:
      tags: [Chat]
      operationId: putProjectStyleSheet
      summary: >
        Write the Apparition line editor's copyeditor style sheet. A separate
        route from PUT /memory because it has a different writer (the desktop
        client, learning from which proposed edits the author accepted and
        rejected) and no dependency on the relationship journal existing —
        PUT /memory requires a non-empty `memory`, which a project that has
        only ever used the line editor doesn't have. Creates the memory row if
        absent, leaves `memory` and `novel_info` untouched if present, and
        bumps the shared version. Read it back via GET /memory.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [style_sheet]
              properties:
                style_sheet:
                  type: [string, "null"]
                  description: >
                    Markdown, capped at 8 KiB — tighter than the other two
                    documents because it is injected into every line-edit
                    turn. Null (or whitespace-only) clears it.
      responses:
        "200":
          description: Style sheet saved.
          content:
            application/json:
              schema:
                type: object
                required: [memory]
                properties:
                  memory:
                    $ref: "#/components/schemas/ChatMemory"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/memory/reflect:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Chat]
      operationId: reflectChatMemory
      summary: >
        Run a reflection ("dream") pass if one is due: the model re-reads
        chat sessions updated since the last reflection and rewrites the
        memory document. Cheap to call unconditionally — the server gates on
        staleness (at least one undreamed session, and a minimum interval
        since the last dream) and returns `reflected: false` without calling
        the LLM when nothing is due. `force` skips the interval gate but
        still requires at least one undreamed session. The response always
        carries the latest memory document.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
      responses:
        "200":
          description: Reflection outcome (ran or not due).
          content:
            application/json:
              schema:
                type: object
                required: [reflected, memory]
                properties:
                  reflected:
                    type: boolean
                  memory:
                    anyOf:
                      - $ref: "#/components/schemas/ChatMemory"
                      - type: "null"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /ws:
    get:
      tags: [Realtime]
      operationId: realtimeUpgrade
      summary: WebSocket upgrade for realtime project_changes events.
      description: >
        This is a WebSocket endpoint, not a plain HTTP GET. OpenAPI cannot
        model WebSocket framing, so this entry exists to (a) document the
        upgrade handshake and (b) carry the message DTOs as schemas so
        clients can generate types for them.


        **Auth on upgrade.** Send two subprotocols on the handshake:
        `ghostkey-v1, bearer.<access_token>`. In a browser-style API:
        `new WebSocket(url, ["ghostkey-v1", "bearer." + accessToken])`.
        The server verifies the bearer before completing the handshake and
        echoes back only `ghostkey-v1` (never the `bearer.*` value).


        **Subscribe.** A connected socket receives no events until it sends
        a `WsSubscription` frame with `type: "subscribe"`. The server acks
        with a `WsSubscriptionAck`. A socket may subscribe to multiple
        projects; subscriptions are per-socket and reset on reconnect.
        After subscribing, the server pushes a `ProjectChangeEvent` for
        every project/document/asset mutation in that project. The server
        never sends file bytes or document content — re-fetch via the REST
        endpoints on each event.
      security: []
      parameters:
        - name: Sec-WebSocket-Protocol
          in: header
          required: true
          description: "Two comma-separated subprotocols: `ghostkey-v1, bearer.<access_token>`."
          schema:
            type: string
      responses:
        "101":
          description: Switching Protocols. The WebSocket is open.
        "400":
          description: The `ghostkey-v1` subprotocol was missing or unknown.
        "401":
          description: The bearer subprotocol was missing or the token was invalid.
  /api/word-count:
    post:
      tags: [Projects]
      operationId: countWords
      summary: Count words server-side.
      description: >
        Count words without the client fetching document bodies. Send `text`
        (or `content`) to count an arbitrary string, or `project_id` to sum the
        words across every non-deleted chapter in a project — the aggregation
        runs in Postgres so clients never pull chapter content just to show a
        total. If both a text field and `project_id` are present, the text
        wins.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WordCountRequest"
      responses:
        "200":
          description: The computed word count.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WordCountResponse"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/freewrite/connect:
    post:
      tags: [Freewrite]
      operationId: connectFreewriteDropbox
      summary: Start the Dropbox OAuth flow for the Freewrite ingest.
      description: >
        Returns the Dropbox consent URL to open in the user's browser. The
        `state` inside it is a short-lived signed JWT naming the user; after
        consent, Dropbox redirects to `/api/integrations/dropbox/callback`,
        which stores the connection. Poll `GET /api/freewrite/connection`
        to notice completion.
      responses:
        "200":
          description: The consent URL.
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    format: uri
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/freewrite/connection:
    get:
      tags: [Freewrite]
      operationId: getFreewriteConnection
      summary: Whether this user's Dropbox is connected.
      responses:
        "200":
          description: Connection state.
          content:
            application/json:
              schema:
                type: object
                required: [connected]
                properties:
                  connected:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      tags: [Freewrite]
      operationId: disconnectFreewriteDropbox
      summary: Disconnect Dropbox (revoke + forget the stored token).
      description: Existing drafts in the pile are kept.
      responses:
        "204":
          description: Disconnected.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/integrations/dropbox/callback:
    get:
      tags: [Freewrite]
      operationId: dropboxOauthCallback
      summary: Browser-facing Dropbox OAuth redirect target.
      description: >
        Not called by API clients. Dropbox redirects the user's browser here
        after the consent screen; the signed `state` identifies the user and
        the code is exchanged and stored server-side. Responds with a small
        human-readable HTML page.
      security: []
      parameters:
        - name: code
          in: query
          required: false
          schema:
            type: string
        - name: state
          in: query
          required: false
          schema:
            type: string
        - name: error
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Connection stored; HTML confirmation page.
          content:
            text/html:
              schema:
                type: string
        "400":
          description: Missing/expired state, canceled consent, or exchange failure (HTML page).
          content:
            text/html:
              schema:
                type: string
  /api/freewrite/sync:
    post:
      tags: [Freewrite]
      operationId: syncFreewriteDrafts
      summary: Run one Dropbox sync cycle into the caller's drafts pile.
      description: >
        Called when the client opens the drafts overlay — there is no
        background poller. Concurrent calls per user share one in-flight
        run. 409 `not_connected` until the user has connected their
        Dropbox via `POST /api/freewrite/connect`.
      responses:
        "200":
          description: What the sync saw and changed.
          content:
            application/json:
              schema:
                type: object
                required: [files, created, updated]
                properties:
                  files:
                    type: integer
                  created:
                    type: integer
                  updated:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: The user has not connected a Dropbox account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/freewrite/drafts:
    get:
      tags: [Freewrite]
      operationId: listFreewriteDrafts
      summary: List the Freewrite drafts pile.
      description: >
        Drafts synced off a Freewrite device (via Postbox's Dropbox sync)
        waiting to be inserted into a book. Newest device activity first.
        Content is excluded — fetch a single draft to read it. Inserting is
        client-side (at the editor cursor); afterwards the client archives
        the draft, which removes it from the pending pile.
      parameters:
        - name: folder
          in: query
          required: false
          description: Narrow to one Freewrite device folder.
          schema:
            type: string
            enum: [A, B, C]
        - name: status
          in: query
          required: false
          description: Defaults to `pending` (the visible pile).
          schema:
            type: string
            enum: [pending, archived, deleted]
      responses:
        "200":
          description: The drafts, without content.
          content:
            application/json:
              schema:
                type: object
                required: [drafts]
                properties:
                  drafts:
                    type: array
                    items:
                      $ref: "#/components/schemas/FreewriteDraftSummary"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/freewrite/drafts/{id}:
    parameters:
      - $ref: "#/components/parameters/FreewriteDraftId"
    get:
      tags: [Freewrite]
      operationId: getFreewriteDraft
      summary: One draft with its full content.
      responses:
        "200":
          description: The draft.
          content:
            application/json:
              schema:
                type: object
                required: [draft]
                properties:
                  draft:
                    $ref: "#/components/schemas/FreewriteDraft"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Freewrite]
      operationId: deleteFreewriteDraft
      summary: Remove a draft from the pile without inserting it.
      description: >
        Soft status flip. New writing on the device (a new Dropbox rev)
        resurfaces the draft as pending.
      responses:
        "204":
          description: Draft marked deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/freewrite/drafts/{id}/archive:
    parameters:
      - $ref: "#/components/parameters/FreewriteDraftId"
    post:
      tags: [Freewrite]
      operationId: archiveFreewriteDraft
      summary: Archive a draft after inserting it into a book.
      description: >
        Called by the client after it inserted the draft's content into a
        chapter at the editor cursor. The server records where the draft
        went and removes it from the pending pile. Idempotent.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                project_id:
                  type: string
                  format: uuid
                document_id:
                  type: string
                  format: uuid
      responses:
        "200":
          description: The archived draft (without content).
          content:
            application/json:
              schema:
                type: object
                required: [draft]
                properties:
                  draft:
                    $ref: "#/components/schemas/FreewriteDraftSummary"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  parameters:
    FileId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    FolderId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ProjectId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DocumentId:
      name: did
      in: path
      required: true
      schema:
        type: string
        format: uuid
    AssetId:
      name: aid
      in: path
      required: true
      schema:
        type: string
        format: uuid
    IncludeNotes:
      name: notes
      in: query
      required: false
      description: >
        Include the project's notes section in the export. Defaults to true;
        pass false to export chapters only.
      schema:
        type: boolean
        default: true
    IllusionTemplateKind:
      name: kind
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/IllusionTemplateKind"
    PageStyleId:
      name: psid
      in: path
      required: true
      schema:
        type: string
        format: uuid
    BoardId:
      name: bid
      in: path
      required: true
      schema:
        type: string
        format: uuid
    FreewriteDraftId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    SessionId:
      name: sid
      in: path
      required: true
      schema:
        type: string
        format: uuid
    Range:
      name: Range
      in: header
      required: false
      schema:
        type: string
  responses:
    InvalidJsonOrBody:
      description: Invalid JSON or request body.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    InvalidId:
      description: Path parameter was not a UUID.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    InvalidFolder:
      description: Folder id was invalid or not visible to the caller.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    Unauthorized:
      description: Missing, invalid, or expired access token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            unauthorized:
              value: { error: unauthorized }
    NotFound:
      description: Resource was not found for the caller.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            not_found:
              value: { error: not_found }
    NameTaken:
      description: Display name already exists in the same folder.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            name_taken:
              value: { error: name_taken }
    FilenameTaken:
      description: Filename already exists in the project.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            filename_taken:
              value: { error: filename_taken }
    ContentTooLarge:
      description: Request content exceeded the documented limit.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            content_too_large:
              value: { error: content_too_large }
    RateLimited:
      description: >
        Rate limit hit — either this server's own limiter (see the
        "Rate limiting" note in the API description) or an upstream provider's.
        `Retry-After` is always present on the former.
      headers:
        Retry-After:
          description: Seconds until the window frees up.
          schema:
            type: integer
        RateLimit-Limit:
          description: Requests permitted in the window for this bucket.
          schema:
            type: integer
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
        RateLimit-Reset:
          description: Seconds until the current window resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            rate_limited:
              value: { error: rate_limited }
    OriginNotAllowed:
      description: >
        A browser sent an `Origin` this server does not serve. Applies to every
        route; non-browser clients (Electron main process, React Native) send no
        `Origin` and never see this.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            origin_not_allowed:
              value: { error: origin_not_allowed }
    UpstreamError:
      description: Gemini or backing service failure.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            upstream_error:
              value: { error: upstream_error }
    Deleted:
      description: Resource is soft-deleted.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            deleted:
              value: { error: deleted }
  schemas:
    ApiError:
      type: object
      required: [error]
      properties:
        error:
          type: string
        detail:
          type: string
          description: >-
            Human-readable explanation of the failure, when one is available —
            typically the upstream provider's own error message. Advisory only:
            never branch on it (branch on `error`), and treat it as unstable
            text fit for logging and diagnostics. Present mainly on the LLM
            routes, where a bare `upstream_error` code otherwise names no cause.
      additionalProperties: true
    AuthCredentials:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          maxLength: 254
        password:
          type: string
          minLength: 1
          maxLength: 256
    SignupCredentials:
      allOf:
        - $ref: "#/components/schemas/AuthCredentials"
        - type: object
          properties:
            password:
              type: string
              minLength: 8
              maxLength: 256
    AuthSession:
      type: object
      required: [access_token, refresh_token, expires_at, user]
      properties:
        access_token:
          type: string
        refresh_token:
          type: string
        expires_at:
          type: string
          format: date-time
          description: Refresh-token expiry. Access tokens are short lived.
        user:
          type: object
          required: [id, email]
          properties:
            id:
              type: string
              format: uuid
            email:
              type: string
              format: email
    Health:
      type: object
      required: [ok, realtime]
      properties:
        ok:
          type: boolean
        realtime:
          type: object
          required: [listener, users, sockets]
          properties:
            listener:
              type: string
              enum: [connecting, connected, reconnecting, stopped]
            users:
              type: integer
            sockets:
              type: integer
    File:
      type: object
      required:
        - id
        - owner_id
        - folder_id
        - name
        - content_type
        - size_bytes
        - version
        - etag
        - status
        - deleted_at
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        folder_id: { type: [string, "null"], format: uuid }
        name: { type: string }
        content_type: { type: string }
        size_bytes: { type: integer }
        version: { type: integer }
        etag: { type: [string, "null"] }
        status:
          type: string
          enum: [ready]
        deleted_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    FilePatch:
      type: object
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        folder_id:
          type: [string, "null"]
          format: uuid
    Folder:
      type: object
      required: [id, owner_id, parent_id, name, deleted_at, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        parent_id: { type: [string, "null"], format: uuid }
        name: { type: string }
        deleted_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    FolderCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        parent_id:
          type: [string, "null"]
          format: uuid
    FolderPatch:
      type: object
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        parent_id:
          type: [string, "null"]
          format: uuid
    Project:
      type: object
      required:
        - id
        - owner_id
        - folder_id
        - name
        - title
        - description
        - author
        - format_version
        - cover_filename
        - cover_thumbnail
        - saved_prompts
        - editing_plan
        - freewrite_folder
        - metadata_version
        - original_created_at
        - deleted_at
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        owner_id:
          type: string
          format: uuid
        folder_id:
          type: [string, "null"]
          format: uuid
        name:
          type: string
        title:
          type: string
        description:
          type: string
        author:
          type: string
        format_version:
          type: integer
        cover_filename:
          type: string
        cover_thumbnail:
          anyOf:
            - $ref: "#/components/schemas/CoverThumbnail"
            - type: "null"
        saved_prompts:
          type: array
          items:
            $ref: "#/components/schemas/SavedPrompt"
        editing_plan:
          anyOf:
            - $ref: "#/components/schemas/EditingPlan"
            - type: "null"
        freewrite_folder:
          description: Freewrite device folder this book reads drafts from; null = untied.
          type: [string, "null"]
          enum: [A, B, C, null]
        metadata_version:
          type: integer
        original_created_at:
          type: string
          format: date-time
        deleted_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ProjectDetail:
      type: object
      required: [project, chapters, notes, assets]
      properties:
        project:
          $ref: "#/components/schemas/Project"
        chapters:
          type: array
          items:
            $ref: "#/components/schemas/ChaptersListEntry"
        notes:
          type: array
          items:
            $ref: "#/components/schemas/DocumentSummary"
        assets:
          type: array
          items:
            $ref: "#/components/schemas/Asset"
    ProjectPatch:
      type: object
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        title:
          type: string
          maxLength: 255
        description:
          type: string
          maxLength: 4096
        author:
          type: string
          maxLength: 255
        cover_filename:
          type: string
        chapters:
          type: array
          items:
            $ref: "#/components/schemas/ChapterOrderInput"
        notes:
          type: array
          items:
            $ref: "#/components/schemas/DocumentRef"
        saved_prompts:
          type: array
          items:
            $ref: "#/components/schemas/SavedPrompt"
        editing_plan:
          description: The Editing Plan, or null to clear it.
          anyOf:
            - $ref: "#/components/schemas/EditingPlan"
            - type: "null"
        folder_id:
          type: [string, "null"]
          format: uuid
        freewrite_folder:
          description: Freewrite device folder to read drafts from, or null to untie.
          type: [string, "null"]
          enum: [A, B, C, null]
    CoverThumbnail:
      type: object
      required: [status, url, width, height, content_type, size_bytes, etag, updated_at]
      properties:
        status:
          type: string
          enum: [available, ready]
        url:
          type: string
        width:
          type: [integer, "null"]
        height:
          type: [integer, "null"]
        content_type:
          type: string
        size_bytes:
          type: [integer, "null"]
        etag:
          type: [string, "null"]
        updated_at:
          type: [string, "null"]
          format: date-time
    ChaptersListEntry:
      oneOf:
        - $ref: "#/components/schemas/DocumentSummary"
        - type: object
          required: [name, chapters]
          properties:
            name:
              type: string
            chapters:
              type: array
              items:
                $ref: "#/components/schemas/DocumentSummary"
          additionalProperties: false
    DocumentRef:
      description: >
        A reference to a document in the chapter/note ordering. Identity is the
        document `id`; the server stores/reads ordering by id and treats
        `filename` as a duplicatable display title (sent for legacy fallback and
        convenience). Clients should send `id`.
      type: object
      required: [filename]
      properties:
        id:
          type: string
          format: uuid
        filename:
          type: string
          minLength: 1
          maxLength: 255
      additionalProperties: true
    ChapterOrderInput:
      oneOf:
        - $ref: "#/components/schemas/DocumentRef"
        - type: object
          required: [name, chapters]
          properties:
            name:
              type: string
            chapters:
              type: array
              items:
                $ref: "#/components/schemas/DocumentRef"
          additionalProperties: false
    SavedPrompt:
      type: object
      required: [id, name, prompt]
      properties:
        id:
          type: string
        name:
          type: string
        prompt:
          type: string
        task_list_json:
          type: string
    EditingPlan:
      description: >
        The author's brief for how the manuscript should be read — a single
        freeform text gathered before PhantomMemory's Chat and threaded into
        its prompts. One client-owned object per project;
        the server stores it opaquely. A null `editing_plan` on the project
        means no plan yet. (Rows written before July 2026 may carry the old
        structured shape — manuscript_state/focus/genre/audience/comps —
        which clients flatten to text on read.)
      type: object
      required: [context, updated_at]
      properties:
        context:
          description: >
            Freeform context about the book: genre, audience, where the draft
            stands, what the author wants feedback on — whatever they choose
            to share.
          type: string
        updated_at:
          type: string
          format: date-time
    DocumentKind:
      type: string
      enum: [chapter, note]
    DocumentSummary:
      type: object
      required: [id, kind, filename, version, updated_at]
      properties:
        id:
          type: string
          format: uuid
        kind:
          $ref: "#/components/schemas/DocumentKind"
        filename:
          type: string
        version:
          type: integer
        updated_at:
          type: string
          format: date-time
    Document:
      allOf:
        - $ref: "#/components/schemas/DocumentSummary"
        - type: object
          required: [project_id, owner_id, content, deleted_at, created_at]
          properties:
            project_id:
              type: string
              format: uuid
            owner_id:
              type: string
              format: uuid
            content:
              type: string
            deleted_at:
              type: [string, "null"]
              format: date-time
            created_at:
              type: string
              format: date-time
    Asset:
      type: object
      required:
        - id
        - project_id
        - owner_id
        - filename
        - content_type
        - size_bytes
        - etag
        - deleted_at
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        owner_id:
          type: string
          format: uuid
        filename:
          type: string
        content_type:
          type: string
        size_bytes:
          type: integer
        etag:
          type: [string, "null"]
        deleted_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ChapterVersion:
      type: object
      required:
        - id
        - project_id
        - document_id
        - owner_id
        - document_version
        - content
        - word_count
        - created_at
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        document_id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        document_version: { type: integer }
        content: { type: string }
        word_count: { type: integer }
        created_at: { type: string, format: date-time }
    ChapterOutline:
      type: object
      description: >
        One chapter's entry in the reverse outline (condensed reading).
        Generated client-side and cached server-side; the server stores it
        opaquely and only requires `chapter` + `summary` — every other field
        is optional and passed through verbatim (additionalProperties
        allowed), so the client can evolve the outline schema without a
        server change. Enum-like values below are advisory (documented, not
        enforced).
      required:
        - chapter
        - summary
      additionalProperties: true
      properties:
        chapter:
          type: string
          description: The chapter's filename as it appears in the assembled manuscript header.
        summary:
          type: string
          description: Narrative summary of the chapter, present tense.
        pov:
          type: string
          description: The chapter's POV character.
        pov_evidence:
          type: string
          description: Brief narration cue supporting the POV call.
        pov_interior:
          type: object
          additionalProperties: true
          description: >
            The POV character's interior track for the chapter. `status` is
            one of `new | deeper | unchanged` (advisory).
          properties:
            want: { type: string }
            fear: { type: string }
            shift: { type: string }
            status: { type: string }
        density_note:
          type: string
          description: >
            One line on where the chapter's weight sits — external events,
            interior movement, or both.
        other_lead:
          nullable: true
          type: string
          description: >
            The non-POV lead's apparent want/fear/decision, only as evidenced
            on the page; null when they neither appear nor cast a shadow.
        relational_beat:
          type: string
          description: >
            One of `attraction | resistance | forced_proximity | vulnerability
            | rupture | false_reconciliation | reconciliation | separation |
            longing | none` (advisory).
        relational_distance:
          nullable: true
          type: string
          description: >
            One of `closes | opens | holds` (advisory), judged against the
            declared central relationship relative to its last on-page
            chapter; null when the relationship is off the page.
        distance_evidence:
          nullable: true
          type: string
          description: Concrete textual anchor for the distance call (≤1 line).
        word_count:
          type: integer
          description: >
            The chapter's word count, computed by the client pipeline from
            the chapter text (never by the model).
    ContinuityFinding:
      type: object
      description: >
        One continuity finding. Generated client-side and cached server-side;
        the server stores it opaquely and only requires `chapter` — every
        other field is optional and passed through verbatim
        (additionalProperties allowed), so the client can evolve the finding
        schema without a server change. Enum-like values below are advisory
        (documented, not enforced).
      required:
        - chapter
      additionalProperties: true
      properties:
        chapter:
          type: string
          description: Filename of the chapter where the finding surfaces.
        kind:
          type: string
          description: One of `hard_error | arc_drift` (advisory).
        category:
          type: string
          description: >
            Hard-error categories `physical_fact | timeline | knowledge |
            geography | world_rule | object_possession`; arc-drift kinds
            `missed_shift | unmotivated_deviation` (advisory).
        entity:
          type: string
          description: The character/object/place/rule the finding is about.
        quote:
          type: string
          description: Verbatim quote from the flagged chapter.
        prior_chapter:
          type: string
          description: Filename of the chapter the finding contradicts.
        prior_quote:
          type: string
          description: Verbatim quote from the prior chapter.
        explanation:
          type: string
          description: Why this cannot be intentional development.
        verified:
          type: boolean
          description: >
            Whether the adversarial verify pass confirmed the finding
            (false when verification failed and the finding is kept
            provisionally).
    ContinuityReport:
      type: object
      description: >
        The cached continuity report — the client-orchestrated three-pass
        check's result. Opaque to the server beyond the two required finding
        arrays; extra fields (stats, timestamps) pass through verbatim.
      required:
        - hard_errors
        - arc_drift
      additionalProperties: true
      properties:
        hard_errors:
          type: array
          items:
            $ref: "#/components/schemas/ContinuityFinding"
        arc_drift:
          type: array
          items:
            $ref: "#/components/schemas/ContinuityFinding"
    IllusionTemplateKind:
      type: string
      enum: [page_setup, title_page, chapter_header, body]
    IllusionStyles:
      type: object
      description: >
        Opaque client-owned style JSON — partial overrides over the
        client's built-in defaults. The server never interprets it; it
        only caps the serialized size at 32 KiB.
      additionalProperties: true
    IllusionTemplate:
      type: object
      required:
        - id
        - project_id
        - owner_id
        - kind
        - styles
        - version
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        kind:
          $ref: "#/components/schemas/IllusionTemplateKind"
        styles:
          $ref: "#/components/schemas/IllusionStyles"
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    IllusionPageStyle:
      type: object
      required:
        - id
        - project_id
        - owner_id
        - document_id
        - page_index
        - styles
        - version
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        document_id: { type: [string, "null"], format: uuid }
        page_index: { type: integer }
        styles:
          $ref: "#/components/schemas/IllusionStyles"
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SeanceBlocks:
      type: array
      description: >
        Opaque client-owned block array for a Séance board canvas. The
        server never interprets it; it only validates that it's an array
        and caps the serialized size at 1 MiB. Each block carries its own
        geometry (x, y, width, z) and content (text, or an assetId
        reference into the project's assets).
      items:
        type: object
        additionalProperties: true
    SeanceBoardSummary:
      type: object
      required:
        - id
        - name
        - sort_index
        - version
        - updated_at
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        sort_index: { type: integer }
        version: { type: integer }
        updated_at: { type: string, format: date-time }
    SeanceBoard:
      type: object
      required:
        - id
        - project_id
        - owner_id
        - name
        - blocks
        - sort_index
        - version
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        name: { type: string }
        blocks:
          $ref: "#/components/schemas/SeanceBlocks"
        sort_index: { type: integer }
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProjectPlan:
      type: object
      required: [format_version, chapters]
      additionalProperties: true
      description: >
        The plan behind Poltergeist's board — a worklist over the manuscript.
        Client-owned JSON: the server validates only this minimal shape (plus
        a 1 MiB size cap) and passes everything else through verbatim, so
        client-side schema iterations survive the round-trip without a server
        deploy. Client-owned fields (format v2) include per-chapter linked
        document ids, writer notes, pending actions (write / rewrite /
        line_edit / move / delete) with their content anchors
        (version + word count + minhash fingerprint), and completion history.
      properties:
        format_version:
          type: integer
          description: Client-owned plan format version.
        chapters:
          type: array
          description: Plan chapters; array order is the plan order.
          items:
            type: object
            required: [id, title]
            additionalProperties: true
            properties:
              id:
                type: string
                description: Stable client-minted board row id.
              title: { type: string }
    ProjectTaskItem:
      type: object
      required: [id, title]
      additionalProperties: true
      description: >
        One manual task. Client-owned JSON: the server validates only
        id/title and passes everything else through verbatim. Client-owned
        fields (format v1) include `created_at` and `completed_at`
        timestamps; a task with a null `completed_at` is open.
      properties:
        id:
          type: string
          description: Stable client-minted task id.
        title: { type: string }
    ProjectTasks:
      type: object
      required: [format_version, tasks]
      additionalProperties: true
      description: >
        Poltergeist's manual task list. Read whole, mutated one task at a
        time via the add/update/remove ops.
      properties:
        format_version:
          type: integer
          description: Client-owned tasks format version.
        tasks:
          type: array
          description: Tasks; array order is the list order.
          items:
            $ref: "#/components/schemas/ProjectTaskItem"
    WordStatsDay:
      type: object
      required: [day, total, written]
      properties:
        day:
          type: string
          format: date
          description: Local calendar day, per the request's tz_offset.
        total:
          type: integer
          description: >
            The manuscript's size at the end of that day. Falls when chapters
            or passages are cut.
        written:
          type: integer
          description: >
            Words of work that day. Words added, rewritten, and deleted all
            count, and pasting, Freewrite inserts, dictation, and DOCX import
            all count on the way in — so this never goes negative and is not
            the change in `total`.
    ChatSessionMessages:
      type: array
      description: >
        A saved PhantomMemory Chat transcript: the conversation as an
        ordered list of role/text turns. The server validates the shape
        and caps the serialized size at 1 MiB but never interprets the
        content. Ephemeral per-turn tool traces are not persisted.
      items:
        type: object
        required: [role, text]
        properties:
          role: { type: string, enum: [user, assistant] }
          text: { type: string }
    ChatSessionSummary:
      type: object
      required:
        - id
        - title
        - version
        - updated_at
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        version: { type: integer }
        updated_at: { type: string, format: date-time }
    ChatSession:
      type: object
      required:
        - id
        - project_id
        - owner_id
        - title
        - messages
        - version
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        title: { type: string }
        messages:
          $ref: "#/components/schemas/ChatSessionMessages"
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ChatMemory:
      type: object
      description: >
        Three model-written markdown documents per project, sharing one row.
        `memory` (the relationship journal — how to work with this author,
        injected into every chat turn) and `novel_info` (the story bible —
        durable novel facts + author intent, shared as a canon prior with the
        chat, continuity checker, and outline generators) are
        rewritten together by the server-side reflection pass over saved chat
        sessions; `last_reflected_at` is its high-water mark, and sessions
        updated after it haven't been dreamed over yet. `style_sheet` is the
        desktop line editor's copyeditor style sheet (canonical spellings,
        British vs US, punctuation habits, standing instructions), written
        through its own endpoint and never touched by the reflection pass.
        All three nullable fields are null until first written.
      required:
        - project_id
        - owner_id
        - memory
        - novel_info
        - style_sheet
        - version
        - last_reflected_at
        - created_at
        - updated_at
      properties:
        project_id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        memory: { type: string }
        novel_info: { type: [string, "null"] }
        style_sheet: { type: [string, "null"] }
        version: { type: integer }
        last_reflected_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    AgentRequest:
      type: object
      required: [system, prompt]
      description: >
        Body for POST /api/projects/{id}/agent. The client owns intent and prompt
        authoring; the server resolves the requested project context
        deterministically and assembles the full user prompt. `system` and
        `prompt` must be present as strings but may both be empty — analysis
        callers send no user turn and let the fetched context be the effective
        prompt.
      properties:
        system:
          type: string
          description: May be empty, but must be present (a string).
        prompt:
          type: string
          description: >
            May be empty (analysis callers send no user turn); must be
            present as a string.
        context:
          type: array
          description: >
            Project-context entries to fetch and inline as labeled sections
            (after the `## Project: <title>` header, before the prompt).
          items:
            oneOf:
              - type: object
                required: [kind]
                properties:
                  kind: { type: string, enum: [manuscript] }
              - type: object
                required: [kind]
                properties:
                  kind: { type: string, enum: [notes] }
              - type: object
                required: [kind, filename]
                properties:
                  kind: { type: string, enum: [file] }
                  filename:
                    type: string
                    description: Target document filename (chapter or note).
        inline:
          type: array
          description: Client-supplied text blocks, each its own labeled section.
          items:
            type: object
            required: [label, text]
            properties:
              label: { type: string }
              text: { type: string }
        max_tokens:
          type: integer
          minimum: 1
          maximum: 65536
          default: 65536
        json:
          type: boolean
          description: >
            Constrain the model to emit syntactically valid JSON (for analysis
            callers that parse the output). Only honored on the default Gemini
            stream (no `sources`), where it enables constrained JSON decoding
            (`responseMimeType: application/json`); ignored on the single-model
            and native tool-calling paths.
        sources:
          type: array
          description: >
            Model selection. Exactly one id, streaming just that model tagged
            `source: "default"` (the chat model picker). When absent/empty,
            a single Gemini stream tagged `source: "default"`. More than one
            entry is rejected with `invalid_sources`.

            `gemini-flash` is the rolling newest-stable Flash alias, covering
            both the cheap utility jobs (Poltergeist's chapter-change note) and
            the manuscript-adjacent ones (desktop agent modes, the reverse
            outline writer); `minimax-m3` writes the machine-facing artifacts
            (AI outline, world bible).
          items:
            type: string
            enum: [opus-4-8, fable-5, gpt-5-5, gpt-5-6-sol, gemini-3-1-pro, gemini-flash, glm-5-2, kimi-k3, qwen3-8-max, minimax-m3]
        tools:
          type: array
          description: >
            Native tool definitions (Anthropic tool use), passed through to the
            model verbatim. Only honored on the native multi-turn path
            (`messages` present, single Claude source); rejected otherwise.
          items:
            type: object
            required: [name, input_schema]
            properties:
              name: { type: string }
              description: { type: string }
              input_schema:
                type: object
                description: JSON Schema for the tool's input object.
        messages:
          type: array
          description: >
            Multi-turn conversation for native tool calling. Requires `sources`
            to name exactly one Claude model (opus-4-8 or fable-5); `prompt` is
            ignored. Content blocks are passed through to the Messages API
            verbatim — an assistant turn must echo the stream's `message` frame
            content EXACTLY (thinking blocks included; the API validates their
            signatures). Tool results ride user turns as `tool_result` blocks.
            The server still injects the project header and any fetched
            `context` sections at the top of the first user message. The first
            and last entries must be `user` turns.
          items:
            type: object
            required: [role, content]
            properties:
              role: { type: string, enum: [user, assistant] }
              content:
                description: >
                  Plain text, or an array of Anthropic content blocks (passed
                  through verbatim).
                oneOf:
                  - type: string
                  - type: array
                    items: { type: object }
    SemanticSearchRequest:
      type: object
      required: [query]
      description: Body for POST /api/projects/{id}/semantic-search.
      properties:
        query:
          type: string
          minLength: 1
          maxLength: 2000
          description: Natural-language description of what to find.
        top_k:
          type: integer
          minimum: 1
          maximum: 20
          default: 8
          description: Maximum number of chunks to return.
        chapter:
          type: string
          description: >
            Optional exact chapter filename to scope the search to (the client
            resolves display names / numbers to filenames).
    SemanticSearchResponse:
      type: object
      required: [hits, reindexed]
      properties:
        hits:
          type: array
          description: Most similar chunks, best first.
          items:
            type: object
            required: [filename, chunk_index, text, score]
            properties:
              filename:
                type: string
                description: Chapter the chunk belongs to.
              chunk_index:
                type: integer
                description: Position of the chunk within its chapter.
              text:
                type: string
                description: The chunk's verbatim text.
              score:
                type: number
                description: Cosine similarity in [-1, 1]; higher is closer.
        reindexed:
          type: integer
          description: Chapters (re-)embedded by this request — 0 on the warm path.
    WordCountRequest:
      type: object
      description: >
        Provide exactly one source of words: a `text`/`content` string, or a
        `project_id`. At least one is required.
      minProperties: 1
      properties:
        text:
          type: string
          description: Arbitrary text to count. Takes precedence over `content`.
        content:
          type: string
          description: Alias for `text`, matching the local document field name.
        project_id:
          type: string
          format: uuid
          description: Sum words across every non-deleted chapter in this project.
    WordCountResponse:
      type: object
      required: [word_count]
      properties:
        word_count:
          type: integer
    WsSubscription:
      description: >
        Client → server frame. The only message the server reads on a
        WebSocket. Scopes (or unscopes) this socket's fanout to a project.
      type: object
      required: [type, project_id]
      properties:
        type:
          type: string
          enum: [subscribe, unsubscribe]
        project_id:
          type: string
          format: uuid
      additionalProperties: false
    WsSubscriptionAck:
      description: >
        Server → client acknowledgement of a WsSubscription frame.
      type: object
      required: [type, project_id]
      properties:
        type:
          type: string
          enum: [subscribed, unsubscribed]
        project_id:
          type: string
          format: uuid
    JobKind:
      type: string
      enum: [reverse_outline, ai_outline, continuity, world_bible, book_map]
      description: >
        The server-side pipeline a job runs. `reverse_outline` — the
        author-facing outline (incremental by default); `ai_outline` — the
        machine-facing story outline (incremental by default, same engine);
        `continuity` — the three-pass
        continuity check; `world_bible` — whole-manuscript entity extraction
        into the world bible (cache hit when nothing changed, artifact at
        GET /bible); `book_map` — arc-first acts + scenes derived from the
        AI story outline (generates the outline first on a cold cache;
        cache hit when nothing changed, artifact at GET /book-map).
    JobStatus:
      type: string
      enum: [running, done, error, cancelled]
    JobProgress:
      description: >
        Overall progress. The pipeline computes the pass-weighted `fraction`
        (it knows its own pass structure); clients just draw one honest bar.
        `indeterminate` is true when the pass has no meaningful total yet.
      type: object
      required: [label, current, total, fraction, indeterminate]
      properties:
        label:
          type: string
        current:
          type: integer
        total:
          type: integer
        fraction:
          type: number
        indeterminate:
          type: boolean
    JobQuestionOption:
      type: object
      required: [id, label]
      properties:
        id:
          type: string
        label:
          type: string
        input:
          type: object
          description: Picking this option opens a free-text input; the label submits it.
          required: [placeholder]
          properties:
            placeholder:
              type: string
    JobQuestion:
      description: >
        A non-blocking author question raised by a running pipeline (e.g.
        continuity's "which is the story?"). Answer via the job answer
        route; question ids are stable across runs and rehydrations.
      type: object
      required: [id, question, options]
      properties:
        id:
          type: string
        question:
          type: string
        detail:
          type: string
          description: Supporting evidence (verbatim quotes etc.), rendered under the question.
        options:
          type: array
          items:
            $ref: "#/components/schemas/JobQuestionOption"
    JobSnapshot:
      description: >
        One job's state. `id` is a row uuid for real runs, or the synthetic
        `cq-<projectId>` id for rehydrated continuity questions — plain
        string, not uuid-format. `error` is a wire error code
        (`interrupted` when the running machine died or was redeployed).
      type: object
      required:
        - id
        - kind
        - project_id
        - label
        - status
        - progress
        - questions
        - error
        - summary
        - cancel_requested
        - started_at
        - finished_at
      properties:
        id:
          type: string
        kind:
          $ref: "#/components/schemas/JobKind"
        project_id:
          type: string
          format: uuid
        label:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        progress:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/JobProgress"
        questions:
          type: array
          items:
            $ref: "#/components/schemas/JobQuestion"
        error:
          type: [string, "null"]
        summary:
          type: [string, "null"]
        cancel_requested:
          type: boolean
        started_at:
          type: string
          format: date-time
        finished_at:
          type: [string, "null"]
          format: date-time
    JobStartRequest:
      type: object
      required: [kind]
      properties:
        kind:
          $ref: "#/components/schemas/JobKind"
        force:
          type: boolean
          description: Skip the artifact cache and regenerate (the "Re-analyze" / "Re-run" button).
    JobAnswerRequest:
      type: object
      required: [question_id, option_id]
      properties:
        question_id:
          type: string
        option_id:
          type: string
        text:
          type: string
          description: The free-text input, when the chosen option carries one.
    BibleEntity:
      description: >
        One world-bible entity card from the whole-manuscript extraction.
        `name` is the canonical display form (post-override;
        `extracted_name` records what the extraction chose before
        overrides), `aliases` every other surface form the manuscript uses,
        `typos` the forms the author marked as typos (kept on the card for
        identity, excluded from the derived spellings), `facts` what the
        book establishes about the entity in book order, `chapters` the
        chapter filenames mentioning it (computed server-side by text scan).
        `key` is the normalized identity the overrides attach to.
      type: object
      required:
        - key
        - type
        - name
        - extracted_name
        - aliases
        - typos
        - first_appearance
        - mention_count
        - hidden
        - facts
        - chapters
      properties:
        key:
          type: string
        type:
          type: string
          enum: [character, place, term]
        name:
          type: string
        extracted_name:
          type: string
        aliases:
          type: array
          items:
            type: string
        typos:
          type: array
          items:
            type: string
        first_appearance:
          type: string
          description: Chapter filename of the earliest mention.
        mention_count:
          type: integer
          description: Number of chapters mentioning the entity.
        hidden:
          type: boolean
        facts:
          type: array
          items:
            type: string
        chapters:
          type: array
          items:
            type: string
          description: Chapter filenames mentioning the entity, in manuscript order.
    BibleOverrides:
      description: >
        The author's edits, the only client-writable part of the bible. An
        override attaches to the card whose name/alias set contains `key`
        (case- and diacritic-insensitive match); unmatched overrides stay
        dormant and reattach when the entity reappears. At most 500 entries,
        strings at most 200 chars.
      type: object
      required: [entities]
      properties:
        entities:
          type: array
          maxItems: 500
          items:
            type: object
            required: [key]
            properties:
              key:
                type: string
                maxLength: 200
              rename:
                type: string
                maxLength: 200
                description: New canonical spelling (old name becomes an alias).
              add_aliases:
                type: array
                maxItems: 20
                items:
                  type: string
                  maxLength: 200
              typos:
                type: array
                maxItems: 20
                items:
                  type: string
                  maxLength: 200
                description: >
                  Exact surface forms (never normalized — a typo may differ
                  from the canonical form only by a diacritic) to mark as
                  typos: moved out of `aliases` into `typos` and excluded
                  from the derived spellings.
              hidden:
                type: boolean
    Bible:
      type: object
      required: [format_version, generated_at, entities, overrides]
      properties:
        format_version:
          type: integer
        generated_at:
          type: string
          format: date-time
        entities:
          type: array
          items:
            $ref: "#/components/schemas/BibleEntity"
        overrides:
          $ref: "#/components/schemas/BibleOverrides"
    BibleResponse:
      type: object
      required: [bible, spellings]
      properties:
        bible:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Bible"
        spellings:
          type: array
          items:
            type: string
          description: >
            Flat canonical-spellings list derived from the visible cards —
            what the transcribe/OCR proxies inject. Empty when bible is null.
    BookMapScene:
      description: One scene of an act, in act order.
      type: object
      required: [title, description, action, reaction, reflection]
      properties:
        title:
          type: string
        description:
          type: string
        action:
          type: string
          description: What happens that causes the scene.
        reaction:
          type: string
          description: How the POV character reacts.
        reflection:
          type: string
          description: How the POV character reflects on it.
    BookMapAct:
      description: >
        One act of the MAIN arc. The five structural elements track the
        character arc (internal/relational beats count as concrete);
        `external_pressure` is where the external plot lives, demoted to
        its actual role. Elements the story doesn't supply carry an
        explicit "Missing — …" string rather than an invented beat.
      type: object
      required:
        - title
        - description
        - inciting_incident
        - complication
        - crisis
        - climax
        - conclusion
        - external_pressure
        - scenes
      properties:
        title:
          type: string
        description:
          type: string
        inciting_incident:
          type: string
        complication:
          type: string
        crisis:
          type: string
        climax:
          type: string
        conclusion:
          type: string
        external_pressure:
          type: string
          description: What the external plot does in this act to force the arc forward.
        scenes:
          type: array
          items:
            $ref: "#/components/schemas/BookMapScene"
    BookMapMainArc:
      description: >
        The model's up-front commitment to which plotline is the story,
        made before any act mapping — rendered as the map's header.
      type: object
      required: [protagonist, want, conflict, growth, external_pressure]
      properties:
        protagonist:
          type: string
          description: >
            Who carries the main arc — a lead, both leads mirrored, or the
            relationship itself.
        want:
          type: string
        conflict:
          type: string
          description: The main conflict and its kind (internal / relational / external).
        growth:
          type: string
        external_pressure:
          type: string
          description: The external plot, named, and its role as amplifier.
    BookMap:
      type: object
      required: [format_version, generated_at, main_arc, acts]
      properties:
        format_version:
          type: integer
        generated_at:
          type: string
          format: date-time
        main_arc:
          $ref: "#/components/schemas/BookMapMainArc"
        acts:
          type: array
          items:
            $ref: "#/components/schemas/BookMapAct"
    BookMapResponse:
      type: object
      required: [book_map]
      properties:
        book_map:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/BookMap"
    ProjectChangeEvent:
      description: >
        Server → client realtime event. Pushed only to sockets subscribed
        to the event's project_id. `version` is the project's
        metadata_version for entity `project`, the document's version for
        entity `document`, and absent for entities `asset`, `job`, and
        `bible`. On an `entity: "job"` event, re-fetch
        `GET /api/projects/{id}/jobs/{jobId}` (`kind: delete` means the row
        is gone — drop the card). On an `entity: "bible"` event, re-fetch
        `GET /api/projects/{id}/bible` if the bible is on screen.
      type: object
      required: [type, kind, entity, project_id, id]
      properties:
        type:
          type: string
          const: project_changes
        kind:
          type: string
          enum: [insert, update, delete]
        entity:
          type: string
          enum: [project, document, asset, job, bible]
        project_id:
          type: string
          format: uuid
        id:
          type: string
          format: uuid
          description: Equals project_id when entity is `project`.
        version:
          type: integer
    FreewriteDraftSummary:
      description: >
        A draft synced off a Freewrite device, without its content.
        `freewrite_folder` is the device folder letter (null when the
        Dropbox layout didn't carry one). The inserted_* ids record where
        an archived draft went; both are null while the draft is pending.
      type: object
      required:
        - id
        - freewrite_folder
        - title
        - word_count
        - status
        - device_updated_at
        - inserted_project_id
        - inserted_document_id
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        freewrite_folder:
          type: [string, "null"]
          enum: [A, B, C, null]
        title:
          type: string
        word_count:
          type: integer
        status:
          type: string
          enum: [pending, archived, deleted]
        device_updated_at:
          type: [string, "null"]
          format: date-time
        inserted_project_id:
          type: [string, "null"]
          format: uuid
        inserted_document_id:
          type: [string, "null"]
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    FreewriteDraft:
      allOf:
        - $ref: "#/components/schemas/FreewriteDraftSummary"
        - type: object
          required: [content]
          properties:
            content:
              type: string
