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.


    **Auth transport.** Two ways to carry a session, chosen by the client.
    *Bearer* (the default, and the only option for the native clients) puts the
    access token in `Authorization: Bearer …` and the refresh token in the
    request body, exactly as the endpoints below describe. *Cookie* is for a
    browser served from **this server's own origin**: the client sends
    `x-gk-auth: cookie` on every call, and the session routes then answer with
    `Set-Cookie` — `gk_access` (15 min, `Path=/`) and `gk_refresh` (30 days,
    `Path=/api/auth`), both `HttpOnly; Secure; SameSite=Strict` — instead of
    returning real tokens. The `access_token` / `refresh_token` fields still
    appear in the body but hold an opaque per-response marker with no
    credential value; echo them back where the contract asks for a token, or
    omit the field entirely. The point is that page script cannot read the
    refresh token, so an XSS bug cannot walk off with a 30-day credential.

    Cookie auth is honoured **only** on requests whose `Sec-Fetch-Site` is
    `same-origin` or `same-site` — so a web app on a sibling subdomain
    (`ghost-key.app` calling `api.ghost-key.app`) qualifies, while anything on
    a different registrable domain does not. A request missing the header —
    including one from a browser too old to send it — is answered `401` rather
    than silently downgraded, so a caller cannot ask for the weaker transport.

    Because the opt-in is a custom header, a cross-origin browser preflights
    before every authed call; `x-gk-auth` is on the `Access-Control-Allow-Headers`
    list for that reason.


    **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: Admin
  - name: Auth
  - name: Billing
  - name: Flags
  - name: Files
  - name: Folders
  - name: Series
  - name: Projects
  - name: Drafts
  - 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/admin/me:
    get:
      tags: [Admin]
      operationId: getAdminMe
      summary: The backoffice gate.
      description: >
        200 identifies the operator; 403 means an ordinary account. Admin is a
        flag on the user row set by hand in SQL — no route grants it. The
        backoffice client renders nothing until this answers 200.
      responses:
        "200":
          description: Caller is an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminIdentity"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/usage:
    get:
      tags: [Admin]
      operationId: getAdminUsage
      summary: LLM spend grouped by feature, then by model.
      description: >
        Operator-wide totals across every account — this is the one read that
        deliberately carries no `owner_id` predicate, with `requireAdmin`
        standing in for it.


        `cost_micros` is null rather than 0 when no model in the group had a
        published rate, which is a different fact from "this was free";
        `unpriced_calls` counts how many calls are missing from a cost, and
        `priced_models` lists the slugs that do carry one. Token counts are
        always exact — only the dollar figures depend on the rate table.


        Embedding calls are a known gap: Gemini's batchEmbedContents returns no
        usage block, so they record no row. Dictation's ElevenLabs leg is billed
        per audio-second and is out of scope for a token ledger; only its Gemini
        cleanup pass appears here.
      parameters:
        - in: query
          name: days
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
          description: Window size, clamped server-side.
        - in: query
          name: owner
          required: false
          schema:
            type: string
            format: uuid
          description: >
            Narrow the same report to one account. Omitted, it covers every
            account. This is an operator's filter on a cross-account view, not
            the `owner_id` predicate of invariant 5 — it is chosen in the
            backoffice and never taken from the caller's own session. Ids come
            from `GET /api/admin/usage/by-account`.
      responses:
        "200":
          description: Spend report.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SpendReport"
        "400":
          description: "`owner` was not a UUID."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                invalidOwner:
                  value: { error: invalid_owner }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/usage/by-account:
    get:
      tags: [Admin]
      operationId: getAdminUsageByAccount
      summary: LLM spend grouped by the account that spent it.
      description: >
        The same window as `GET /api/admin/usage`, grouped by who spent it
        rather than by what it was spent on — the question a per-feature total
        cannot answer, since one author regenerating a world bible nightly and
        the same money spread across the house look identical there.


        Carries billing standing for nobody and manuscripts for nobody: an
        email, token counts and a cost. Accounts with no model call in the
        window do not appear at all, so an empty list means a quiet window, not
        an empty database.


        Only the `limit` heaviest spenders are listed, but
        `total_cost_micros` / `total_unpriced_calls` / `account_count` are
        computed over *every* account in the window — so a truncated list still
        reports the house honestly. Follow a row with
        `GET /api/admin/usage?owner=<owner_id>` for that account's per-feature
        breakdown.
      parameters:
        - in: query
          name: days
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
          description: Window size, clamped server-side.
      responses:
        "200":
          description: Spend by account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountSpendReport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/rates:
    get:
      tags: [Admin]
      operationId: getAdminRates
      summary: Per-model rates, plus models that have spent with no rate.
      description: >
        Rates are US dollars per million tokens, stored in `model_rates` and
        edited here rather than in code. They shipped as placeholders written
        from the shape of provider pricing, not read off it, so every seeded row
        carries `note: "unverified placeholder"` — clearing the note is how an
        operator marks a rate as checked.


        `unpriced_models` lists slugs that appear in `llm_usage` with no rate
        row, so the editor can offer them instead of asking anyone to type a
        model slug from memory.
      responses:
        "200":
          description: Current rates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelRates"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      tags: [Admin]
      operationId: putAdminRate
      summary: Create or update one model's rate.
      description: >
        Upsert keyed on `model`. Takes effect on the **next** model call:
        `llm_usage.cost_micros` is frozen at write time, so already-recorded
        rows keep their old cost until `POST /api/admin/rates/recalculate` is
        run.


        The cache fields are optional and default to 0, which is the honest
        value for providers that never bill them (only Anthropic bills a cache
        write separately).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ModelRateInput"
      responses:
        "200":
          description: The stored rate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelRate"
        "400":
          description: Malformed body, unusable model id, or a rate outside 0–10000.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/rates/recalculate:
    post:
      tags: [Admin]
      operationId: recalculateAdminRates
      summary: Reprice recorded spend from the current rates.
      description: >
        The deliberate exception to `llm_usage` being append-only. Cost is
        frozen at write time so an accounting ledger's past doesn't move on its
        own — but the rates shipped as placeholders, so without this the first
        correction would leave every already-recorded row wrong forever. It is a
        POST an operator presses; nothing automatic calls it.


        Rows whose model still has no rate go back to a null cost — unpriced,
        not zero.
      responses:
        "200":
          description: How many rows changed, and how many remain unpriced.
          content:
            application/json:
              schema:
                type: object
                required: [updated, still_unpriced]
                properties:
                  updated:
                    type: integer
                    description: Rows whose cost actually changed.
                  still_unpriced:
                    type: integer
                    description: Rows left with a null cost (model has no rate).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/cover-artists:
    get:
      tags: [Admin]
      operationId: getCoverArtists
      summary: The curated cover-artist directory — the backoffice's editable view.
      description: >
        Global product data, not user data: the operator hand-fills the
        directory and every author sees it on Glamour's cover page (via the
        cover-brief route). Writes are admin-only; a future artist-signup
        feature would land on this table.
      responses:
        "200":
          description: Every directory entry, alphabetically.
          content:
            application/json:
              schema:
                type: object
                required: [artists]
                properties:
                  artists:
                    type: array
                    items:
                      $ref: "#/components/schemas/CoverArtist"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Admin]
      operationId: createCoverArtist
      summary: Add one artist to the directory.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CoverArtistInput"
      responses:
        "201":
          description: The stored entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CoverArtistEnvelope"
        "400":
          description: Malformed body or a field over its length bound (`invalid_artist`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/cover-artists/{artistId}:
    parameters:
      - $ref: "#/components/parameters/ArtistId"
    patch:
      tags: [Admin]
      operationId: updateCoverArtist
      summary: Edit one directory entry (full field set, not a sparse patch).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CoverArtistInput"
      responses:
        "200":
          description: The updated entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CoverArtistEnvelope"
        "400":
          description: Malformed body or a field over its length bound (`invalid_artist`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Admin]
      operationId: deleteCoverArtist
      summary: Remove one directory entry.
      description: >
        Stored takeaways keep rendering their picks (resolved at run time);
        the directory change flags them stale instead of dangling them.
      responses:
        "204":
          description: Entry removed.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/admin/users:
    get:
      tags: [Admin]
      operationId: searchAdminAccounts
      summary: Accounts by email, with the plan each is on and what put them there.
      description: >
        The operator's account search, for comping a plan. Like the spend
        report it carries no `owner_id` predicate and `requireAdmin` stands in
        for one.


        It returns billing standing and nothing else - no projects, no
        documents, no tokens - so granting a plan never becomes a way to read
        an author's manuscript. `plan` is the value in force right now, the
        same one every gate in the product reads; `subscription` and `grant`
        are there to explain it, and either may be null while `plan` is not.


        An empty `q` answers with the newest accounts. The result is capped at
        `limit` with no paging: this is a lookup by address, not a directory.
      parameters:
        - in: query
          name: q
          required: false
          schema:
            type: string
          description: Substring of the email, case-insensitive. Empty means the newest accounts.
      responses:
        "200":
          description: Matching accounts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccountPage"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/users/{userId}/plan-grant:
    parameters:
      - $ref: "#/components/parameters/UserId"
    put:
      tags: [Admin]
      operationId: grantPlan
      summary: Put an account on a plan without Stripe.
      description: >
        A comped author, a reviewer, a beta reader, a support make-good. The
        grant bills nothing, invoices nothing and renews never, but downstream
        it does exactly what a paid plan does - access gates and quota read the
        plan from one place that reconciles subscriptions and grants together.


        At most one grant is live per account: writing a second replaces the
        first (the old row is kept, revoked, as the audit trail). The higher
        tier wins over any subscription, so a grant can only ever add - comping
        Basic to an account paying for Pro leaves them on Pro, and the response
        says so rather than echoing what was asked for.


        Quota still applies, and it counts against the calendar month whatever
        the grant's own window is: a twelve-month grant carrying its own period
        would hand out one month's allowance to last a year.


        `free` is not grantable - it is where an account already falls back to,
        so an operator reaching for it wants DELETE.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlanGrantInput"
      responses:
        "200":
          description: The account, with the grant now in force.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccountEnvelope"
        "400":
          description: >
            Malformed body (`invalid_body`, `invalid_json`), or an `expires_at`
            already in the past (`expiry_in_the_past`) - which would read back
            as no grant at all.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Admin]
      operationId: revokePlanGrant
      summary: End an account's grant now.
      description: >
        Never touches a subscription - an account that also pays keeps what it
        pays for. 404 when there was no live grant to end, so a no-op is not
        reported as a revocation.
      responses:
        "200":
          description: The account, with the grant gone.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccountEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/admin/flags:
    get:
      tags: [Admin]
      operationId: listAdminFlags
      summary: The flag registry with its stored state.
      description: >
        Every declared feature flag with its operator-facing label, its code
        default, the global switch (null when the flag sits at its default)
        and any per-user canary overrides. The registry itself lives in server
        code — this surface can describe and toggle flags, never add one.
      responses:
        "200":
          description: The registry, in declaration order. Empty when no flags are declared.
          content:
            application/json:
              schema:
                type: object
                required: [flags]
                properties:
                  flags:
                    type: array
                    items:
                      $ref: "#/components/schemas/AdminFlag"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /api/admin/flags/{flag}:
    parameters:
      - in: path
        name: flag
        required: true
        schema:
          type: string
        description: The flag id, from the registry.
    put:
      tags: [Admin]
      operationId: setAdminFlag
      summary: Set or clear one row of a flag's state.
      description: >
        Without `user_id` this writes the global switch; with one it writes
        that account's canary override — the mechanism for trying a dark
        feature on one prod account before flipping it for everyone.


        `enabled: null` clears the row, restoring the next rung of the
        resolution ladder: clearing an override falls back to the global
        switch, clearing the global falls back to the code default. Clearing a
        row that does not exist is a 404, not a silent success.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FlagWriteInput"
      responses:
        "200":
          description: The flag, with the write in force.
          content:
            application/json:
              schema:
                type: object
                required: [flag]
                properties:
                  flag:
                    $ref: "#/components/schemas/AdminFlag"
        "400":
          description: Malformed body (`invalid_body`) or a malformed `user_id` (`invalid_user`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >
            No such flag in the registry (`unknown_flag`), no such account
            (`user_not_found`), or a clear that found nothing to clear
            (`not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /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
              properties:
                refresh_token:
                  type: string
                  minLength: 1
                  description: >
                    Required on bearer transport. On cookie transport the
                    `gk_refresh` cookie wins and this field is only a fallback
                    for a client still holding a real token from before it
                    switched transports — send the marker back or omit it.
      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
              properties:
                refresh_token:
                  type: string
                  minLength: 1
                  description: >
                    Required on bearer transport. On cookie transport the
                    `gk_refresh` cookie wins and this field is only a fallback
                    for a client still holding a real token from before it
                    switched transports — send the marker back or omit it.
      responses:
        "204":
          description: >
            Token revoked if known. Always no content. On cookie transport the
            response also expires both auth cookies, even when the token was
            already unknown.
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/auth/verify-email:
    get:
      tags: [Auth]
      operationId: verifyEmail
      security: []
      description: >
        The target of the link in the verification email. A `GET`, because what
        opens it is a mail client on whatever device the author reads on — no
        session, no app, nothing to POST with. The token is single-use and
        expires 24 h after it was issued.


        The response is always a `303` to
        `<APP_BASE_URL>/verify-email?status=<outcome>`; the token is spent
        before the redirect, so nothing secret reaches the client. Clients do
        not call this route — they render the landing page it redirects to.


        Note `already_verified` is answered for a token belonging to an account
        that is *already* confirmed, whether or not that token has been spent.
        Mail scanners follow links before the human does, so a second arrival at
        the same link is the common case and must not read as a broken one.
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string, minLength: 1 }
      responses:
        "303":
          description: >
            Verification attempted; the outcome is in the `status` query
            parameter of the `Location`. A missing token, one that was never
            issued, one already spent by a different account, and an expired one
            are all `invalid` — the page has nothing useful to say about which,
            and the way out of every one of them is the same resend.
          headers:
            Location:
              schema:
                type: string
                format: uri
                enum:
                  - "<APP_BASE_URL>/verify-email?status=verified"
                  - "<APP_BASE_URL>/verify-email?status=already_verified"
                  - "<APP_BASE_URL>/verify-email?status=invalid"
                  - "<APP_BASE_URL>/verify-email?status=error"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/auth/resend-verification:
    post:
      tags: [Auth]
      operationId: resendVerification
      description: >
        Issues a fresh verification link and emails it to the **signed-in**
        account. Any link issued earlier stops working.


        Authenticated, unlike the rest of the pre-session auth surface: the
        server signs unverified accounts in like any other, so the only caller
        that ever needs this is one holding a token. Taking the address from the
        token rather than the body leaves nothing to probe with, which is what
        lets this route answer honestly instead of returning a uniform empty
        `200` the way `/api/auth/forgot-password` must.


        A repeat within 60 s of the previous send answers `429` with the seconds
        left. Clients count down the number they are given rather than keeping
        their own copy of 60 — the cooldown is server-owned and may change.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: No fields; the account comes from the access token.
      responses:
        "200":
          description: A fresh verification email has been sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResendVerificationAccepted"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: The address is already verified — there is nothing to send.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                already_verified:
                  value: { error: already_verified }
        "429":
          description: >
            Asked again inside the cooldown, or past the per-address mail
            budget. `retry_after` is how many seconds to wait — carried in the
            body as well as in `Retry-After`, so a client counting it down does
            not have to reach past its own error shape for it.
          headers:
            Retry-After:
              description: Seconds until another resend is accepted.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResendVerificationCooldown"
              examples:
                rate_limited:
                  value: { error: rate_limited, retry_after: 43 }
        "502":
          description: The mail provider rejected the send. Nothing arrived; try again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                email_send_failed:
                  value: { error: email_send_failed }
        "503":
          description: >
            This deployment has no mail provider configured, so it cannot send
            verification email at all. Not a retryable condition.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                email_not_configured:
                  value: { error: email_not_configured }
  /api/auth/forgot-password:
    post:
      tags: [Auth]
      operationId: forgotPassword
      security: []
      description: >
        Emails a password-reset link carrying a single-use token valid for
        1 h. Answers an empty `200` whether or not the address has an account,
        with constant response time — neither shape nor latency reveals
        account existence. The per-address mail budget applies; past it the
        route answers `200` without sending.


        The link points at `<APP_BASE_URL>/reset-password?token=<token>` — a
        client screen, not a route here, because unlike verification a reset
        needs somewhere to type a new password. The token therefore reaches the
        client, which spends it on `/api/auth/reset-password`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
      responses:
        "200":
          description: Accepted. Always empty, regardless of account existence.
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/auth/reset-password:
    post:
      tags: [Auth]
      operationId: resetPassword
      security: []
      description: >
        Consumes a reset token from the password-reset email and sets the new
        password. On success every active session is revoked — the user signs
        in again on all devices with the new password.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token:
                  type: string
                  minLength: 1
                password:
                  type: string
                  minLength: 8
                  maxLength: 256
      responses:
        "200":
          description: Password changed and all sessions revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ok"
        "400":
          description: >
            Invalid body, or the token is unknown, already used, or expired
            (`invalid_token`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                invalid_token:
                  value: { error: invalid_token }
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/stripe/checkout:
    post:
      tags: [Billing]
      operationId: createCheckoutSession
      description: >
        Creates a Stripe Checkout session for a subscription to the given plan
        and returns its URL for the client to open in the system browser. The
        Stripe customer is created on first use and remembered on the account.

        Whether the subscription starts with a free trial is the server's
        decision, never the request's: an account that has never held a
        subscription gets the rung's `PlanPrice.trial_days` (card collected
        now, charged when the trial ends); every other account pays today.
        `SubscriptionSnapshot.trial_eligible` is the same answer, readable
        beforehand, so a button can say which it will be.

        Only ever *starts* a subscription. An account that already has an
        amendable one gets `409 subscription_exists` — changing plan goes
        through the billing portal, and resuming a cancelled-but-not-yet-lapsed
        subscription goes through PATCH /api/billing/subscription. Without this
        an author could buy a second subscription and be billed for both.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan:
                  type: string
                  enum: [basic, standard, pro]
                attribution:
                  $ref: "#/components/schemas/Attribution"
      responses:
        "200":
          description: Checkout session created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingRedirect"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: The account already has a subscription to amend.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                subscription_exists:
                  value: { error: subscription_exists }
        "503":
          $ref: "#/components/responses/BillingNotConfigured"
  /api/stripe/portal:
    post:
      tags: [Billing]
      operationId: createBillingPortalSession
      description: >
        Creates a Stripe customer-portal session (manage payment method,
        change plan, cancel) and returns its URL for the client to open in
        the system browser. Requires that the account has been through
        checkout at least once.
      responses:
        "200":
          description: Portal session created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingRedirect"
        "400":
          description: The account has no Stripe customer yet.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                no_subscription:
                  value: { error: no_subscription }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/BillingNotConfigured"
  /api/stripe/webhook:
    post:
      tags: [Billing]
      operationId: stripeWebhook
      security: []
      description: >
        Stripe's webhook receiver — called by Stripe, never by clients.
        Verifies the `Stripe-Signature` header against the endpoint secret,
        then applies subscription lifecycle events (checkout completed,
        subscription created/updated/deleted, payment failed) to the account's
        subscription row. Exempt from IP rate limiting; unhandled event types
        are acknowledged and ignored.

        Answers 200 for any event it handled, including one it deliberately
        ignored (an unknown price, a customer no account holds) — those never
        resolve on a retry, and Stripe's retry schedule runs for days. Only a
        thrown handler, which is the genuinely transient case, answers 500 to
        ask for the retry.
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              description: Raw Stripe event payload, verified by signature.
      responses:
        "200":
          description: Event processed (or acknowledged and ignored). Empty.
        "400":
          description: Missing or invalid `Stripe-Signature`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                invalid_signature:
                  value: { error: invalid_signature }
        "503":
          description: The webhook endpoint secret is not configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                webhook_not_configured:
                  value: { error: webhook_not_configured }
  /api/billing/access:
    get:
      tags: [Billing]
      operationId: getBillingAccess
      description: >
        Which surfaces the caller's plan can reach at all — the plan-access
        table, granted and denied, with server-owned labels.

        Distinct from usage limits: an entry here is a boolean fact about
        availability, never a count. "How much of it" is the quota surface's
        question.

        Returns denied capabilities too, unlike the quota read. A lock badge
        that cannot name the plan that opens it is a dead end, and the
        alternative is every client hardcoding the plan ladder. Denied entries
        carry no counts, so no limit information leaks.

        **Cosmetic by contract.** Every lock this feeds is also enforced at the
        surface that does the work, so a stale snapshot degrades to a 403 and
        never to a bypass. Clients must not treat it as the gate.
      responses:
        "200":
          description: The caller's plan and its full capability table.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccessSnapshot"
        "401":
          description: Missing or invalid access token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                unauthorized:
                  value: { error: unauthorized }
  /api/billing/plans:
    get:
      tags: [Billing]
      operationId: getBillingPlans
      security: []
      description: >
        What each rung of the plan ladder costs, read from Stripe.

        **Unauthenticated, and the only route under /api/billing that is.** A
        price is a public fact — it is on the marketing page, which is served
        from the web origin with no session — so gating it would mean only
        someone who had already signed up could be shown what signing up costs.
        Nothing account-specific is in the response.

        Distinct from GET /api/billing/subscription, which answers "what am I
        paying". This answers "what does it cost", identically for everyone.

        No amount is stored in this codebase. Clients must not carry their own
        copy to fall back on: two surfaces quoting different numbers is exactly
        what this replaced, and a stale hardcoded figure is worse than a
        missing one because it looks authoritative.

        Cached per machine for ten minutes, so a price edit in Stripe lands
        without a deploy.
      responses:
        "200":
          description: >
            Every plan on the ladder, cheapest first. Always 200 — an
            unreachable or unconfigured Stripe degrades to null amounts rather
            than an error, because the caller is a pricing page that has to
            render either way.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlanCatalog"
              examples:
                mixedCurrency:
                  summary: A deployment whose Pro price was created in the wrong currency
                  value:
                    currency: usd
                    plans:
                      - { plan: free, amount: 0, currency: null, interval: null }
                      - { plan: basic, amount: 499, currency: usd, interval: month }
                      - { plan: standard, amount: 1499, currency: usd, interval: month }
                      - { plan: pro, amount: 4999, currency: eur, interval: month }
  /api/billing/quota:
    get:
      tags: [Billing]
      operationId: getBillingQuota
      description: >
        How much of each counted feature the caller has left this period.

        Only the features their own plan is provisioned for, with server-owned
        labels — what a *different* tier would give them is pricing-page copy,
        and GET /api/billing/access is what answers "could I have this at all".
        A feature added after a client shipped therefore counts and labels
        itself with no client deploy.

        Deliberately separate from GET /api/billing/access, which clients cache
        for five minutes: a plan changes on a webhook, but a count changes on
        the author's own click, and a "2 left" badge that still says 2 after a
        run is worse than no badge.

        **Cosmetic by contract.** Every number here is re-derived at the
        surface that spends, so a stale count degrades to a `402
        quota_exceeded` in front of the author, never to a free run.
      responses:
        "200":
          description: The caller's plan and what remains of each counted feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuotaSnapshot"
        "401":
          description: Missing or invalid access token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                unauthorized:
                  value: { error: unauthorized }
  /api/billing/subscription:
    get:
      tags: [Billing]
      operationId: getBillingSubscription
      description: >
        The account's billing state — the plan in force, and the Stripe
        subscription behind it (status, renewal date, whether it has been
        cancelled).

        Deliberately separate from GET /api/billing/access. Access is fetched by
        every room on mount and cached; billing state belongs to neither that
        cadence nor that cache. Access answers "what may I do"; this answers
        "what am I paying for, and until when".

        Unlike the access read, this reports lapsed and past-due subscriptions
        too — that is exactly what an account screen has to explain.
      responses:
        "200":
          description: >
            The caller's plan and subscription. `subscription` is null for an
            account that has never subscribed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionSnapshot"
        "401":
          $ref: "#/components/responses/Unauthorized"
    patch:
      tags: [Billing]
      operationId: updateBillingSubscription
      description: >
        Amend the account's existing subscription: change its plan, or cancel
        and resume it. Exactly one of the two bodies.

        `plan` moves the subscription onto another rung, and is the only way up
        or down from inside the app — POST /api/stripe/checkout refuses (409)
        once an amendable subscription exists, because Stripe would otherwise
        create a second and bill for both. The difference is prorated onto the
        next invoice rather than charged on the spot, so the new plan is in
        force immediately and nothing here can fail a card or trigger 3-D
        Secure. `cancel_at_period_end` is left as it stands: an upgrade is not
        a decision to un-cancel.

        `cancel_at_period_end` sets Stripe's flag of the same name; it never
        deletes. The subscription stays active and the author keeps the plan
        they paid for until the period ends, so resubscribing before that date
        is this same route with `false` — no second checkout and no second
        charge. An account whose subscription has already lapsed to `canceled`
        has nothing to amend and gets `no_subscription`; it must go through
        checkout again.

        The subscription acted on is looked up from the caller's own account —
        there is no id in the request.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [cancel_at_period_end]
                  properties:
                    cancel_at_period_end:
                      type: boolean
                - type: object
                  required: [plan]
                  properties:
                    plan:
                      type: string
                      enum: [basic, standard, pro]
                    attribution:
                      $ref: "#/components/schemas/Attribution"
      responses:
        "200":
          description: The updated billing state, in the same shape as the GET.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionSnapshot"
        "400":
          description: Invalid body, or the account has no amendable subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                no_subscription:
                  value: { error: no_subscription }
                invalid_body:
                  value: { error: invalid_body }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: >
            Stripe declined the plan change and the subscription is unchanged.
            The usual cause is a price in a currency the customer is pinned
            away from — Stripe fixes a customer's currency at their first
            subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                plan_change_rejected:
                  value: { error: plan_change_rejected }
        "503":
          $ref: "#/components/responses/BillingNotConfigured"
  /api/flags:
    get:
      tags: [Flags]
      operationId: getFlags
      security: []
      summary: Every feature flag's value for this caller.
      description: >
        Runtime feature toggles — the third gating axis beside plan access
        (may this plan?) and quota (is there any left?). The flag vocabulary
        lives in the server (src/lib/flags/policy.ts); this returns each
        flag's resolved value for the caller.


        Auth is optional. A request carrying a valid access token gets
        per-user overrides applied over the global state; a request without
        one — the signed-out landing and signup pages — gets the global
        resolution. An expired token degrades to the anonymous answer rather
        than a 401, because the page asking may have no session to refresh.


        **Cosmetic by contract**, same as the access snapshot: clients hide
        flagged-off UI from this, but every flagged surface is enforced again
        server-side (403 `feature_disabled`), so a stale snapshot degrades to
        a refusal and never to a bypass. Clients key on the flag id but must
        never enumerate the vocabulary; an id they have never heard of is
        ignored, and an id missing from the snapshot means the feature
        launched (treat as on) or never existed.
      responses:
        "200":
          description: The resolved flag map. Empty when no flags are declared.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FlagSnapshot"
  /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/series:
    get:
      tags: [Series]
      operationId: listSeries
      summary: The caller's series, with live book counts.
      description: >
        Includes implicit single-book series — every project belongs to exactly
        one series, and a book created on its own gets an implicit one named
        after it. Clients render an implicit series with a single book as a
        bare project row; the server does not filter, because only the client
        knows what it is drawing.
      responses:
        "200":
          description: Flat series list.
          content:
            application/json:
              schema:
                type: object
                required: [series]
                properties:
                  series:
                    type: array
                    items:
                      $ref: "#/components/schemas/Series"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Series]
      operationId: createSeries
      summary: Create a named series.
      description: >
        Always explicit — implicit series are only ever born beside a project.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SeriesCreate"
      responses:
        "201":
          description: Series created.
          content:
            application/json:
              schema:
                type: object
                required: [series]
                properties:
                  series:
                    $ref: "#/components/schemas/Series"
        "400":
          description: Invalid body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/NameTaken"
  /api/series/{id}:
    parameters:
      - $ref: "#/components/parameters/SeriesId"
    patch:
      tags: [Series]
      operationId: updateSeries
      summary: Rename or reorder a series.
      description: >
        Naming a series is also what makes it explicit, so there is no
        `implicit` field on the wire — an implicit series is simply one nobody
        has named yet.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SeriesPatch"
      responses:
        "200":
          description: Series renamed and/or reordered.
          content:
            application/json:
              schema:
                type: object
                required: [series]
                properties:
                  series:
                    $ref: "#/components/schemas/Series"
        "400":
          description: Invalid id or body.
          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: [Series]
      operationId: deleteSeries
      summary: Soft-delete the series, keeping its books.
      description: >
        Every book is first rehomed onto a fresh implicit series of its own,
        each carrying a copy of the shared world-bible author layer, so nothing
        the author typed is lost by deleting the grouping. Series-scoped assets
        (entity portraits) follow the first book, since the bytes exist once.
      responses:
        "204":
          description: Series soft-deleted; its books survive, rehomed.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/series/{id}/assets:
    parameters:
      - $ref: "#/components/parameters/SeriesId"
    get:
      tags: [Series]
      operationId: listSeriesAssets
      responses:
        "200":
          description: The series' live assets.
          content:
            application/json:
              schema:
                type: object
                required: [assets]
                properties:
                  assets:
                    type: array
                    items:
                      $ref: "#/components/schemas/Asset"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Series]
      operationId: uploadSeriesAsset
      summary: Upload a series-scoped asset (today, an entity portrait).
      description: >
        Same request and response shape as POST /api/projects/{id}/assets. The
        scope differs because a picture of a character is not a picture of a
        book: attached from book 1 it has to render from book 2, and the
        project-scoped route filters on project_id so it could not.


        Unlike project assets there is NO filename uniqueness here — a portrait
        is addressed by id, and two books may each have contributed an
        `image.png`.
      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"
        "413":
          description: Asset exceeded 25 MB.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/series/{id}/assets/{aid}:
    parameters:
      - $ref: "#/components/parameters/SeriesId"
      - $ref: "#/components/parameters/AssetId"
    get:
      tags: [Series]
      operationId: downloadSeriesAsset
      description: >
        **No session required.** Image bytes are addressed by an unguessable
        id (`gen_random_uuid`, 122 random bits) and served as a capability URL,
        because the clients render them in plain `<img src>` tags that can
        carry neither a bearer token nor the cookie opt-in. Unguessable is not
        revocable: a URL that leaks stays readable until the asset is replaced.
        Images only, exactly as `downloadProjectAsset` — anything else takes
        the authenticated path.
      security:
        - {}
        - bearerAuth: []
      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: [Series]
      operationId: deleteSeriesAsset
      responses:
        "204":
          description: Asset soft-deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/series/{id}/art-direction:
    parameters:
      - $ref: "#/components/parameters/SeriesId"
    get:
      tags: [Series]
      operationId: getArtDirection
      summary: How this series' generated images should look.
      description: >
        One sentence of visual direction — medium, framing, palette, lighting,
        mood, and never subject matter. Derived on first read from the first
        book's positioning brief and story bible (chat_memories.novel_info) by
        a cheap model, then cached on the series row, so every picture in the
        series is drawn to the same direction rather than each finding its own.
        A series with neither written yet gets a neutral line back, uncached,
        so a later brief still produces a real one. Feeds `style` on
        POST /api/images.


        Per series, not per book, because entity portraits are series assets:
        one shared picture slot drawn to two directions is the wobble this
        exists to remove.
      responses:
        "200":
          description: The series' art direction.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ArtDirection"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    put:
      tags: [Series]
      operationId: putArtDirection
      summary: Replace the art direction by hand.
      description: >
        One sentence governs every image in the series, so it has to be
        correctable. An empty string clears it and the next GET derives a fresh
        one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ArtDirection"
      responses:
        "200":
          description: The stored art direction.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ArtDirection"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /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
      description: >-
        Creates the project, its implicit series and its first draft. With
        `starter_chapter`, the draft also gets one empty chapter ("Chapter 1")
        so the manuscript is not a blank list; callers that fill the book
        themselves (DOCX import, fixtures) omit it.
      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
                starter_chapter:
                  type: boolean
                  default: false
                  description: >-
                    Create the project with one empty first chapter. The
                    response is the project row either way — read the chapter
                    back from GET /api/projects/{id}.
      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}/drafts:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Drafts]
      operationId: listDrafts
      summary: Every draft of this book.
      description: >
        A draft is a named, ordered set of chapter documents. Exactly one per
        project is active, and only the active one is writable — every other
        surface (`GET /api/projects/{id}`, exports, jobs, word counts) reports
        the active draft alone. Notes, assets, boards and every authored
        artifact are project-level and shared by all drafts.

        `outline` summarises the draft's kept AI story outline. Inactive drafts
        keep theirs, so it is the only description of what a frozen draft
        contained; `null` means the draft was never analysed while it was
        active.
      responses:
        "200":
          description: Drafts, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [drafts]
                properties:
                  drafts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Draft"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Drafts]
      operationId: createDraft
      summary: Start a new draft, and make it the one being written in.
      description: >
        Creating and activating are one operation — a draft that exists but is
        not active is a state the product does not have.

        Omit `source` for a blank draft. With `source`, the named draft's
        chapters and folder structure are copied; `include_prose` decides
        whether their text comes too. Either way each new chapter's
        `reference_document_id` points at the chapter it was copied from.

        A prose copy is byte-identical, so the derived caches are carried over
        rather than discarded and the new draft costs no regeneration. A blank
        or structure-only draft discards them (see PUT
        /api/projects/{id}/active-draft) and the manuscript is analysed from
        scratch as it is written.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 120
                source:
                  type: object
                  required: [draft_id, include_prose]
                  properties:
                    draft_id:
                      type: string
                      format: uuid
                    include_prose:
                      type: boolean
                      description: >
                        false copies chapter names and arrangement with empty
                        bodies; true copies the text as well.
      responses:
        "201":
          description: Draft created and activated.
          content:
            application/json:
              schema:
                type: object
                required: [draft, drafts]
                properties:
                  draft:
                    $ref: "#/components/schemas/Draft"
                  drafts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Draft"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown project, or a `source.draft_id` that is not one of its drafts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/drafts/{draftId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/DraftId"
    get:
      tags: [Drafts]
      operationId: getDraftChapters
      summary: One draft's chapters, in reading order.
      description: >
        The same shape as the `chapters` field of `GET /api/projects/{id}`, so
        one client type serves both. Reading an inactive draft's chapter body
        needs no special route — `GET /api/projects/{id}/documents/{did}`
        fetches any document of the project by id.
      responses:
        "200":
          description: The draft and its ordered chapters.
          content:
            application/json:
              schema:
                type: object
                required: [draft, chapters]
                properties:
                  draft:
                    type: object
                    required: [id, name, version]
                    properties:
                      id:
                        type: string
                        format: uuid
                      name:
                        type: string
                      version:
                        type: integer
                  chapters:
                    type: array
                    items:
                      $ref: "#/components/schemas/ChaptersListEntry"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Drafts]
      operationId: updateDraft
      summary: Rename a draft, or rewrite its chapter ordering.
      description: >
        At least one of `name` / `chapters` is required.

        `chapters` was previously a key on `PATCH /api/projects/{id}`. It moved
        here because the ordering belongs to one draft: a single project-level
        tree naming several drafts' documents would make every export and
        analysis append the whole archive. Rewriting the order drops the cached
        continuity report; both outline artifacts survive, since their entries
        carry per-chapter stamps and a reorder reconciles with no model call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 120
                chapters:
                  type: array
                  description: >
                    Ordered chapter list, folders as one-level groups. Entries
                    are document *refs* (`id` is the identity), not the full
                    summaries `GET` returns — a reorder never restates
                    server-owned fields like `version` or `updated_at`.
                  items:
                    $ref: "#/components/schemas/ChapterOrderInput"
      responses:
        "200":
          description: Draft updated.
          content:
            application/json:
              schema:
                type: object
                required: [draft]
                properties:
                  draft:
                    type: object
                    required: [id, name, version]
                    properties:
                      id:
                        type: string
                        format: uuid
                      name:
                        type: string
                      version:
                        type: integer
        "400":
          description: >
            Invalid id, JSON, body, or chapter list (`invalid_id`,
            `invalid_json`, `invalid_body`, `invalid_chapters`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Drafts]
      operationId: deleteDraft
      summary: Soft-delete a draft and its chapters.
      description: >
        The active draft and the last remaining draft are refused: a project
        with no writable manuscript has no valid state. Switch away first.
      responses:
        "204":
          description: Draft and its chapters soft-deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            The draft is the active one (`draft_is_active`) or the only one
            left (`last_draft`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/active-draft:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    put:
      tags: [Drafts]
      operationId: setActiveDraft
      summary: Make a draft the writable one.
      description: >
        Asks every running job on the project to stop (their work describes a
        manuscript that is about to stop being the manuscript), moves the
        pointer, bumps `metadata_version`, and drops the machine-derived caches
        that described the old manuscript: reverse outline, continuity, entity
        dossiers, and derived story maps.

        Deliberately untouched: the world bible (derived, but its overrides are
        the author's and must outlive every redraft — re-running it is a
        button, never a side effect), authored story maps, the authored plan,
        tasks, chat memory, editing plan and saved prompts. The rule is that
        the machine's reading of a manuscript may be discarded because it can
        be rebuilt from the prose; the author's work may not, because it
        cannot.

        The AI story outline needs no invalidation — it is keyed per draft, so
        the outgoing draft keeps its own row as the record of what it
        contained.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [draft_id]
              properties:
                draft_id:
                  type: string
                  format: uuid
      responses:
        "200":
          description: Draft activated; the refreshed list is returned.
          content:
            application/json:
              schema:
                type: object
                required: [drafts]
                properties:
                  drafts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Draft"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "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/html:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Export]
      operationId: exportProjectHtml
      summary: Assemble a print-ready HTML 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
        via print CSS; notes follow at the end unless `notes=false`.


        This is also the PDF path: there is no `/export/pdf`. The server used to
        render this same document with a headless Chromium, which cost the
        container a resident browser; the clients own an HTML → PDF renderer
        already (Electron `printToPDF`, or the browser's print dialog on the
        web), so they fetch this and render it themselves.


        Served as an attachment — the API origin is never a place user content
        executes.
      parameters:
        - $ref: "#/components/parameters/IncludeNotes"
      responses:
        "200":
          description: A complete, self-contained HTML document.
          content:
            text/html:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /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: Start a bulk chapter import from a parsed .docx.
      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.


        The .docx is parsed in the request (so an unreadable file still fails
        here, with `invalid_docx`) but the chapters are written by a
        `docx_import` JOB: a book-length import narrates itself chapter by
        chapter over /ws instead of holding one silent request open. The
        response is therefore **202 with no documents** — they do not exist
        until the job finishes, and the caller learns that from the job, not
        from this call. Follow it through GET /projects/{id}/jobs (or the /ws
        `job` events) and re-fetch the project detail on completion. Every
        chapter lands in one transaction, so a failed or cancelled run leaves
        the project exactly as it was. One import per project at a time:
        starting a second while one runs is `job_running` (409).
      requestBody:
        required: true
        content:
          application/vnd.openxmlformats-officedocument.wordprocessingml.document:
            schema:
              type: string
              format: binary
      responses:
        "202":
          description: >
            The project as it stands (unchanged until the run commits) and the
            snapshot of the job now importing into it.
          content:
            application/json:
              schema:
                type: object
                required: [project, job]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
                  job:
                    $ref: "#/components/schemas/JobSnapshot"
        "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: An import is already running for this project (`job_running`).
          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.
      description: >
        **No session required.** Image bytes are addressed by an unguessable
        id (`gen_random_uuid`, 122 random bits) and served as a capability URL,
        because the clients render them in plain `<img src>` tags that can
        carry neither a bearer token nor the cookie opt-in. Unguessable is not
        revocable: a URL that leaks stays readable until the asset is replaced.
        A `?v=` query parameter (any value that changes with the cover) opts
        the response into `immutable` caching; without one it is served with a
        one-day max-age and revalidated against the ETag.
      security: []
      parameters:
        - $ref: "#/components/parameters/ImageVersion"
      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_id` + `doc_version`
        stamps (passthrough fields) and the outline pipeline diffs them against
        current documents to regenerate only the chapters that changed. The
        `doc_id` is the entry's identity across a rename, so retitling a chapter
        relabels its entry with no model call.
      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_id` + `doc_version` stamps (passthrough fields) and the
        outline pipeline reconciles incrementally, regenerating only the
        chapters that changed — renames and reorders cost no model call. This
        artifact is also warmed proactively: when a chapter-version snapshot
        leaves more than five chapters stale, the server starts the `ai_outline`
        job itself so the next reader finds it warm. Below that threshold the
        cache can lag the manuscript, so the response reports how far:
        `stale_chapter_count` is the number of chapters the `ai_outline` job
        would regenerate right now (0 = current; the full chapter count when
        nothing is cached or the cached format is outdated). A reader that
        needs a current outline starts the `ai_outline` job (non-forced) when
        the count is positive, then re-reads this route.
      responses:
        "200":
          description: The cached AI outline, or null.
          content:
            application/json:
              schema:
                type: object
                required:
                  [outline, central_relationship, format_version, stale_chapter_count]
                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.
                  stale_chapter_count:
                    type: integer
                    description: >
                      How many chapters the ai_outline job would regenerate
                      right now. Always 0 for a `?draft_id=` read — a kept
                      draft's outline is the record of a frozen manuscript and
                      has no drift to measure.
        "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"
    patch:
      tags: [Projects]
      operationId: patchProjectAiOutline
      summary: Apply one author correction to the cached AI-facing story outline.
      description: >
        Corrects the cached outline in place when its reading of the
        manuscript is wrong (wrong POV, missed character, misdescribed
        events): rewrites narrative fields of one chapter entry and/or sets
        `central_relationship`, leaving every other entry and the pipeline
        stamps (`chapter`, `doc_id`, `doc_version`, `word_count`,
        `format_version`) untouched. The correction therefore survives
        renames, reorders and unrelated edits, and is discarded exactly when
        the incremental pipeline legitimately re-reads that chapter (a prose
        edit, a force regen, or a format-version bump). The entry is matched
        by `doc_id` first, exact filename (`chapter`) as fallback. Writes
        always land on the active draft. Driven by the desktop chat's
        `edit_outline` tool.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                doc_id:
                  type: string
                  format: uuid
                  description: Preferred chapter key — the entry's document id.
                chapter:
                  type: string
                  description: Filename fallback for matching the entry.
                set:
                  type: object
                  additionalProperties: false
                  description: >
                    Fields to rewrite on the matched entry. Omitted fields are
                    untouched; a null clears the field. Requires `doc_id` or
                    `chapter`, and at least one key. Correcting `pov` on an
                    entry marked `unattributed` also flips `pov_attribution`
                    to `attributed` unless the request says otherwise.
                  properties:
                    summary:
                      type: string
                      minLength: 1
                    pov:
                      nullable: true
                      type: string
                    pov_attribution:
                      type: string
                      enum: [attributed, unattributed, ambiguous]
                    pov_note:
                      nullable: true
                      type: string
                    pov_interior:
                      nullable: true
                      type: object
                      additionalProperties: false
                      properties:
                        want: { type: string }
                        fear: { type: string }
                        shift: { type: string }
                        status:
                          type: string
                          enum: [new, deeper, unchanged]
                    characters:
                      type: array
                      items: { type: string }
                    time_place:
                      nullable: true
                      type: string
                    relational_beat:
                      type: string
                      enum:
                        [attraction, resistance, forced_proximity, vulnerability,
                         rupture, false_reconciliation, reconciliation,
                         separation, longing, none]
                    relational_distance:
                      nullable: true
                      type: string
                      enum: [closes, opens, holds]
                central_relationship:
                  nullable: true
                  type: string
                  description: Present = write it (null clears); absent = untouched.
      responses:
        "200":
          description: The corrected entry (never the whole outline).
          content:
            application/json:
              schema:
                type: object
                required: [chapter, central_relationship]
                properties:
                  chapter:
                    nullable: true
                    description: >
                      The corrected entry as stored (post-normalization,
                      stamps intact), or null for a
                      central_relationship-only edit.
                    allOf:
                      - $ref: "#/components/schemas/ChapterOutline"
                  central_relationship:
                    nullable: true
                    type: string
        "400":
          description: >
            Invalid id, JSON, or body (`invalid_body`: empty `set`, a `set`
            with no `doc_id`/`chapter`, or a request that changes nothing),
            or an edit the normalizer rejects wholesale (`invalid_edit`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: >
            Project not found (`not_found`), no outline cached yet
            (`no_outline` — generate via the `ai_outline` job first), or no
            entry matched (`chapter_not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "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}/authored-outline:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getAuthoredOutline
      summary: The outline the author wrote, and the chapter breakdown made from it.
      description: >
        The third outline, and the only one a person writes. `/outline` and
        `/ai-outline` are both the machine's reading of prose that already
        exists and are regenerable from it; this is the plan for prose that does
        not exist yet, so nothing can rebuild it. It is project-level and
        survives a draft switch for that reason.


        `text` is the author's markdown. `chapters` is a **proposal** — what the
        `chapter_breakdown` job made of that text, then edited by the author —
        and holds no manuscript: no chapter exists until an explicit commit,
        which creates a new draft rather than touching the one in hand.
        `matched_draft_id` records which draft the proposal's `match` references
        point into; when it is no longer the project's active draft the matches
        are stale, and the client says so rather than re-pointing them.


        When the active draft already holds chapters, the job writes a
        **changeset** instead of a proposal — how the plan changes the book,
        for the author to review — and `changeset` / `changeset_draft_id` /
        `changeset_at` carry it until POST /authored-outline/apply-changeset
        spends it into `chapters`. At most one of `changeset`, `chapters` and
        `committed_draft_id` is meaningfully set.


        An untouched project reads back as an empty outline, never null.
      responses:
        "200":
          description: The stored outline, empty if untouched.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthoredOutline"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Projects]
      operationId: putAuthoredOutline
      summary: Store the author's own edits to the outline or the breakdown.
      description: >
        Deliberately partial: the editor's autosave sends `text` alone and the
        card list sends `chapters` alone. At least one must be present.


        With one deliberate exception, which is not a clobber but a
        consequence: **changing `text` clears `chapters`**, because the
        breakdown is a projection of the outline and cards that no longer
        answer to the prose beside them would still read as current — and the
        next commit would turn them into a real draft off a plan nobody has.
        Re-saving identical text changes nothing, and sending `chapters` in the
        same request wins (that request is the author editing the proposal, not
        the outline). `broken_at` survives a clear: it says when the last
        breakdown ran, which stays true, and is how a client tells "cleared"
        from "never broken down".


        `matched_draft_id`, `broken_at` and `committed_draft_id` are not
        writable here — they are the pipeline's and the commit's record of what
        the proposal was read against, and an author's edit never changes that.


        **A `text` that differs from the stored one clears `chapters` and
        `matched_draft_id`.** The breakdown is a projection of the outline, so
        rewriting the outline voids it — a proposal that no longer answers to
        the prose would otherwise read as current and be committed into a real
        draft. `broken_at` is left alone: it still records when the last
        breakdown ran, and an empty `chapters` beside a set `broken_at` is how
        a cleared proposal is told apart from one never made. Sending
        `chapters` in the same request wins over the reset — that is the author
        editing the proposal, not the outline.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                text:
                  type: string
                  description: The author's outline, markdown.
                chapters:
                  type: array
                  items:
                    $ref: "#/components/schemas/AuthoredOutlineEntry"
      responses:
        "200":
          description: The stored outline.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthoredOutline"
        "400":
          description: Invalid id, JSON, or 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}/authored-outline/commit:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Projects]
      operationId: commitAuthoredOutline
      summary: Turn the approved breakdown into chapters, in a new draft.
      description: >
        Creates one chapter per proposed card, in proposal order, in a **new**
        draft which becomes the one being written in. Nothing that already
        exists is touched — the draft in hand keeps every chapter it had.


        **The proposal is spent by this call**: `chapters` and
        `matched_draft_id` are cleared and `committed_draft_id` is stamped with
        the new draft. The cards are the book now, and the draft's chapter tree
        is where it is read; keeping them would show the same chapters twice,
        one copy of which nothing can be written into. `broken_at` and
        `broken_from` stay, so a client can still tell an outline that has been
        broken down before from one that never has.


        A card whose match the author accepted is created with
        `reference_document_id` pointing at that chapter, and carries its prose
        only where `copy_prose` was set. Because the new draft is not a copy of
        the old one, none of the machine-derived caches are carried across.


        Returns the created chapters in proposal order, each with the card's
        notes, so the caller can seed them. Those notes are written once, at
        creation, and nothing overwrites them afterwards.


        In the same transaction, each card's `entities` are attached to the
        chapter it created in the world bible, and the ones the bible doesn't
        hold yet are added to the series' author layer. Where the author
        accepted a match as a reference, plan attachments on the superseded
        chapter move onto its redraft. `cast` reports what that did. Callers
        holding a cached bible must refetch it: a write to
        `PUT /api/projects/{id}/bible/overrides` replaces the author layer
        wholesale, so an edit sent from a bible fetched before the commit would
        drop everything the commit just wrote.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 120
                  description: What to call the new draft.
      responses:
        "201":
          description: The new draft, the refreshed list, and the chapters created.
          content:
            application/json:
              schema:
                type: object
                required: [draft, drafts, chapters, cast]
                properties:
                  cast:
                    $ref: "#/components/schemas/CastResult"
                  draft:
                    $ref: "#/components/schemas/Draft"
                  drafts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Draft"
                  chapters:
                    type: array
                    description: Created chapters, in proposal order.
                    items:
                      type: object
                      required: [document_id, filename, notes]
                      properties:
                        document_id: { type: string, format: uuid }
                        filename: { type: string }
                        notes:
                          type: string
                          description: The card's notes, for the caller to seed.
        "400":
          description: Invalid id, JSON, or draft name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: >
            `nothing_to_commit` — the breakdown is empty. `matches_stale` — the
            matches were read against a draft that is no longer active, so the
            references would point into a manuscript the author has moved off.
            Both are states the author can fix; break the outline down again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/authored-outline/apply-changeset:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Projects]
      operationId: applyAuthoredOutlineChangeset
      summary: Project the approved changeset into the chapter proposal.
      description: >
        The deterministic second half of the changeset flow. The body carries
        the pending changeset's ops as the author reviewed them — rejected ops
        dropped, titles/notes/reasons possibly edited — and the server projects
        them onto the ACTIVE draft with no model call, so what was approved is
        exactly what lands: every chapter no op names becomes a kept card
        (reference + prose, in its place, in its folder), removes are omitted,
        a rewrite becomes one card referencing the first chapter it retells,
        and adds land after their anchor. An empty `ops` list is a real answer
        — the proposal comes back as the book unchanged.


        The changeset is spent by this call (`changeset` reads back null, the
        proposal in `chapters`), and the ordinary review + commit flow takes
        over. Ops referencing chapters the draft no longer has are ignored
        per-reference rather than failing the apply.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ops]
              properties:
                ops:
                  type: array
                  items:
                    $ref: "#/components/schemas/ChangesetOp"
      responses:
        "200":
          description: The stored outline, with the proposal where the changeset was.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthoredOutline"
        "400":
          description: Invalid id, JSON, or body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: >
            `no_changeset` — nothing is pending (it was spent, cleared, or
            never generated). `changeset_stale` — the changeset was read
            against a draft that is no longer active. Both are states the
            author can fix; run the break again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/bible:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectBible
      summary: Fetch the world bible as seen from this book, or null.
      description: >
        The manuscript-derived world bible: entity cards (characters, places,
        terms) produced by the server-side `world_bible` job — the roster is
        extracted from the AI story outline (ensured fresh first), identity
        only, with the author's overrides applied; descriptions live in the
        per-entity dossiers.


        **The roster spans the SERIES this book belongs to.** Every book's
        extraction merges into one set of cards, so a character introduced in
        book 1 is present in book 2's bible before book 2 mentions her; each
        card's flat `chapters`/`mention_count`/`first_appearance` describe
        THIS book, and `books[]` carries every book's. The author layer
        (renames, hides, notes, appearance, portraits, hand-added entities) is
        shared by the whole series; `planned_chapters` alone is per book.


        `spellings` is the flat canonical-spellings list — the canonical
        `name` of each visible card only (aliases do not contribute) — ranked
        characters → places → terms by SERIES-wide 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.


        Null only when no book in the series has been extracted and the author
        has added nothing.
      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}/chapters/cast:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Projects]
      operationId: putChapterCast
      summary: Say who these chapters are about, and attach them in the bible.
      description: >
        **Replace, per document.** An entry means "this chapter's cast is
        exactly these names": names the bible doesn't hold are added to the
        series' author layer, and attachments this chapter had that the new
        list doesn't name are removed. Removal is scoped to the documents in
        the request — an entity planned for a chapter you didn't send stays
        planned for it.


        The same function the outline commit writes through, so the two can
        never disagree about what a cast means. For the commit the two readings
        coincide: a document created a moment earlier has no attachments to
        lose.


        Deliberately cheap, because the desktop's Chapters view calls it on
        every edit pause: a few row reads and one jsonb write. It never loads
        the manuscript and never calls a model — the names were decided by
        whoever wrote the plan, and this only resolves them against the roster
        and stores ids. New entities are therefore written *without* their
        chapter references; the next bible read computes those.


        `document_id`s this project doesn't have are ignored rather than
        rejected, so a stale client that names a deleted chapter is a no-op and
        not an error. Returns the re-derived bible, so the client cache-swaps
        without a second fetch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chapters]
              properties:
                chapters:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [document_id, entities]
                    properties:
                      document_id:
                        type: string
                        format: uuid
                      entities:
                        type: array
                        maxItems: 200
                        description: The chapter's whole cast, not an addition to it.
                        items:
                          $ref: "#/components/schemas/ChapterCastEntry"
      responses:
        "200":
          description: What was written, and the re-merged bible.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/BibleResponse"
                  - type: object
                    required: [cast]
                    properties:
                      cast:
                        $ref: "#/components/schemas/CastResult"
        "400":
          description: Invalid id, JSON, or body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            The plan does not reach `mara.world_bible_manual`
            (`plan_insufficient`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Project not found or not owned (`not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

  /api/projects/{id}/bible/overrides:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    put:
      tags: [Projects]
      operationId: putProjectBibleOverrides
      summary: Replace the author's bible edits (overrides + added entities).
      description: >
        Full-replace, last-write-wins, over the whole author-writable layer:
        `overrides` (renames, hides, notes on the model's entities) and
        `user_entities` (entities the author added by hand, with their notes).


        **The URL is the book's; what it writes is the SERIES' row.** The
        author edits from inside a book, so asking the client to look its
        series up first would be a round-trip for nothing — but the layer is
        shared, and a note typed in book 1 is the same note in book 3.
        `planned_chapters` is split back out and stored against THIS book,
        because it holds document ids.


        Sibling books are left with stale chapter references on purpose and
        re-scan on their next GET; scanning every book on every note edit would
        cost one manuscript load per book in the series.


        The server re-derives the cards from every book's stored extraction
        merged with the author entities (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);
        unmatched overrides are kept dormant and reattach if a later
        regeneration re-produces the entity. Author entities have this book's
        chapter references recomputed here by a manuscript text scan (no
        model). An author entity whose identity an extraction also found merges
        into that card. The author can fill the bible before ever generating
        one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [overrides]
              properties:
                overrides:
                  $ref: "#/components/schemas/BibleOverrides"
                user_entities:
                  type: array
                  maxItems: 300
                  description: >
                    The author's own entities (full-replace), stored on the
                    series. The derived chapter-reference fields are
                    server-computed per book and ignored on input;
                    `planned_chapters` is stored as sent, against this book.
                    Send each entity's `id` back unchanged — a missing one is
                    read as a new entity and its per-book references are lost.
                  items:
                    $ref: "#/components/schemas/UserEntityInput"
      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: Project not found or not owned (`not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/story-maps:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectStoryMaps
      summary: Every story template filled in for this project, both sources.
      description: >
        A story map is a story template (see `/api/story-templates`) filled
        in: a header plus a tree of parts, each part carrying the named
        beats that template asks for. Two sources share the surface.
        `derived` maps are written by the server-side `book_map` job, which
        maps the cached AI story outline onto the chosen template - never
        the raw manuscript. `authored` maps are the writer's own plan.
        Same shape on both sides, so plan and manuscript are directly
        comparable. Each beat carries its own `label`, copied from the
        template when the map was filled in, so a map stays readable after
        its template is edited or removed. Empty list when nothing has been
        mapped or planned yet.
      responses:
        "200":
          description: The project's story maps, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StoryMapsResponse"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /api/projects/{id}/story-maps/{templateId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/TemplateId"
    put:
      tags: [Projects]
      operationId: putAuthoredStoryMap
      summary: Store the author's own story map for one template.
      description: >
        The row Mara's Cards board autosaves into. **Always the `authored`
        source** — the body never names one, and the `derived` row is the
        `book_map` job's to write. One authored row per (project, template),
        so switching templates never destroys work.


        Deliberately partial, per key: a layout drag sends `layout` alone and
        a typed beat sends `map` alone, and neither can clobber the other's
        half of the row (the same coalesce rule as PUT /authored-outline). At
        least one must be present. A `layout` of `null` clears it — "lay it
        out again".


        **A `map` that differs from the stored one voids the chapter proposal
        made from it** (`AuthoredOutline.chapters` and `matched_draft_id` are
        cleared when `broken_from` is this map; `broken_at` and `broken_from`
        survive). Re-sending an identical map, or a layout-only write, voids
        nothing — the board autosaves, and an autosave must not cost the
        author a proposal they are reviewing. A proposal made from the prose
        outline is never touched from here.


        A first write that carries no `map` lands on an empty map shaped
        like the template, with ids, so the row is always well-formed.
        `template_version` is stamped from the resolved template on every
        map write. Gated on the Cards capability (basic).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                map:
                  $ref: "#/components/schemas/AuthoredStoryMapInput"
                layout:
                  nullable: true
                  allOf:
                    - $ref: "#/components/schemas/StoryMapLayout"
      responses:
        "200":
          description: The stored authored row.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StoryMapResponse"
        "400":
          description: Invalid id, JSON, or body (including a node or beat with no `id`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >
            `not_found` — no such project for this account; `invalid_template`
            — the template id resolves to nothing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                not_found:
                  value: { error: not_found }
                invalid_template:
                  value: { error: invalid_template }
        "413":
          $ref: "#/components/responses/ContentTooLarge"
  /api/projects/{id}/story-maps/{templateId}/adopt:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/TemplateId"
    post:
      tags: [Projects]
      operationId: adoptDerivedStoryMap
      summary: Copy the derived map over the authored one.
      description: >
        "Read the beats from the book, then use these." Copies the `derived`
        row the `book_map` job wrote over the `authored` row for the same
        template — a copy, not a translation: every node and beat comes
        across as the job wrote it, with fresh ids minted on the way, `loose`
        empty, and the `layout` cleared so the board lays the new shape out.
        Each beat **keeps its `chapters`**: on the authored side they are the
        author's pins, and the breakdown reads them as match hints it never
        had from prose.


        Always behind a click, never a side effect of a run. When the
        authored map already holds a filled beat it refuses with 409
        `authored_not_empty` unless `force` is true — the author's second
        yes, the same shape as `outline_sketch`. Voids the chapter proposal
        like any other map edit. Gated on the Cards capability (basic).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  description: Overwrite an authored map that already has content.
      responses:
        "200":
          description: The authored row, now a copy of the derived one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StoryMapResponse"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >
            `not_found` — no such project for this account; `no_derived_map`
            — the job has never mapped this template for this project.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                not_found:
                  value: { error: not_found }
                no_derived_map:
                  value: { error: no_derived_map }
        "409":
          description: The authored map already has content and `force` was not set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                authored_not_empty:
                  value: { error: authored_not_empty }
  /api/projects/{id}/dossiers:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      operationId: getProjectDossiers
      summary: Fetch every cached entity dossier, keyed by entity key.
      description: >
        Deep-dive dossiers for individual world-bible entities, generated by
        the server-side `entity_dossier` job from mention-window excerpts —
        the model reads only the passages around the entity's name/alias
        mentions, never the whole manuscript. Empty map when the job has
        never run. Server-generated only; there is no client PUT. Each
        dossier carries an advisory `stale` flag: true when a chapter it
        excerpted changed since generation (a brand-new mention in a chapter
        it never excerpted is not detected — re-running the job catches
        that). Rows survive manuscript edits; per-dossier fingerprints
        decide freshness on the next run.
      responses:
        "200":
          description: The cached dossiers, keyed by entity key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DossiersResponse"
        "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`,
        `/dossiers`) — 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: patchDocument
      summary: Rename a document, or set the chapter it is written against.
      description: >
        Updates `filename` and/or `reference_document_id` in place; at least one
        is required. 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).

        `reference_document_id` names a chapter in *another* draft of the same
        project — the one Apparition shows beside this chapter while it is
        written. Send `null` to clear it. A reference to a document in another
        project, another account, or the same draft is rejected.

        Neither field bumps `version`: that field tracks the document's
        *content* revision and is the staleness key for every derived artifact
        (embedding index, dossiers, world bible, book map, outlines), and
        neither a retitle nor a pointer at a different document invalidates any
        of them. Clients are still notified — the row's realtime event fires on
        any change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                filename:
                  type: string
                reference_document_id:
                  type: string
                  format: uuid
                  nullable: true
      responses:
        "200":
          description: Document updated. `version` is deliberately unchanged.
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document:
                    $ref: "#/components/schemas/Document"
        "400":
          description: >
            Invalid id, JSON, or body (`invalid_id`, `invalid_json`,
            `invalid_body`), a reference that is not a chapter of this project
            (`invalid_reference`), or one in the same draft as the document
            itself (`reference_same_draft`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "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
      description: >
        **No session required.** Image bytes are addressed by an unguessable
        id (`gen_random_uuid`, 122 random bits) and served as a capability URL,
        because the clients render them in plain `<img src>` tags that can
        carry neither a bearer token nor the cookie opt-in. Unguessable is not
        revocable: a URL that leaks stays readable until the asset is replaced.
        This applies to **images only** (png, jpeg, webp, gif, avif, svg).
        Any other content type falls through to the authenticated,
        owner-scoped path and answers 401 without a session.
      security:
        - {}
        - bearerAuth: []
      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`), `grounding` outside the default Gemini stream
            or combined with `json` (`invalid_grounding`), 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"
        "402":
          description: >
            A counted allowance for this request is spent (`quota_exceeded`) —
            the weekly `phantom_chat` message counter on the `phantom.chat`
            surface, or the model's own counter (`fable_chat`). The body
            carries the exhausted feature, `allowance`, `used`, `period_end`
            (the end of that feature's own window — the UTC week for weekly
            features) and the upgrade fields the quota modal reads. Tool
            continuations are never charged and never see this.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "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. `previous` is likewise
        optional; see the field description.
      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.
                previous:
                  type: string
                  description: >
                    Optional. The manuscript text immediately before the
                    insertion point. Used by the cleanup pass for one purpose:
                    to tell whether a quotation is already open, so a chunk cut
                    mid-line of dialogue is closed rather than re-opened. It is
                    context only and is never echoed into the response (the
                    manuscript-safe diff is still raw-transcript-vs-cleaned).
                    Clamped server-side to the last 600 characters; sending
                    more is harmless but pointless. Omitting it is valid and
                    means the chunk is judged on its own.
      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/images:
    post:
      tags: [LLM]
      operationId: generateImage
      summary: Text-to-image generation (server-held provider key).
      description: >
        Stateless, like /api/transcribe and /api/ocr: no project, no database,
        and no knowledge of what the picture is for. The caller composes the
        prompt and decides where the bytes go — the desktop's world-bible
        portraits hand them straight to POST /api/projects/{id}/assets.
        `style` is what makes a set of images look like a set; it is not
        invented here, it comes from GET /api/projects/{id}/art-direction.
        A well-formed response carrying no image (a refusal, or a model that
        answered in prose) is reported as 502 `no_image`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt:
                  type: string
                  maxLength: 4000
                  description: What to draw, in the caller's own words.
                style:
                  type: string
                  maxLength: 1000
                  description: >
                    How it should be drawn — medium, palette, framing, mood.
                    Omitted, the model picks its own look, which is fine for a
                    one-off and wrong for a set.
                size:
                  type: string
                  enum: [square, portrait, landscape]
                  default: square
                project_id:
                  type: string
                  format: uuid
                  description: >
                    Optional, and used only to attribute the draw's cost to a
                    project on the backoffice spend report — the route itself
                    stays stateless, exactly as `/api/ocr` and
                    `/api/transcribe` do with theirs. A draw is by some margin
                    the most expensive call the product makes (~$0.23), and the
                    cost recorded for it is the amount OpenRouter reports
                    billing, not one derived from the rate table.
                feature:
                  type: string
                  maxLength: 64
                  example: entity_portrait
                  description: >
                    What the picture is *for*, as the spend report should label
                    it. Supplied by the caller for the same reason the agent
                    route asks: this route draws whatever it is given — an
                    entity portrait, a cover — and both arrive as the same
                    request shape, so only the caller knows which. Omitted, the
                    draw lands in `image_unattributed` rather than being
                    guessed into a plausible bucket.
      responses:
        "200":
          description: The generated image.
          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
                    example: image/png
        "400":
          description: Missing prompt, or an unsupported size.
          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.
      description: >
        Gated on the Séance room capability (basic). The listing above is not:
        an account that lapses keeps its boards and can still read them, and
        loses only the making of new ones.
      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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "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.
      description: >
        Gated on the Séance room capability (basic) — this is the autosave, so
        it is where a lapsed account stops being able to work on a board. The
        GET above and the DELETE below carry no such check.
      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"
        "403":
          $ref: "#/components/responses/Forbidden"
        "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.
      description: >
        Not gated on the room capability, unlike the create and the save:
        throwing a board away is the author tidying their own data, and a
        locked delete would strand a lapsed account with boards it can neither
        edit nor remove.
      responses:
        "204":
          description: Board deleted.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/comps:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Glamour]
      operationId: listComps
      summary: >
        List the project's comp titles (Glamour's comp board), in the
        author-managed sort order.
      responses:
        "200":
          description: The project's comps.
          content:
            application/json:
              schema:
                type: object
                required: [comps]
                properties:
                  comps:
                    type: array
                    items:
                      $ref: "#/components/schemas/Comp"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Glamour]
      operationId: addComps
      summary: Add comps by ASIN or Amazon URL (server-side Rainforest lookup).
      description: >
        The server fetches each listing live from the Rainforest API and
        snapshots title/author/blurb/market extras — clients send identifiers,
        never metadata. The cover image is copied into Storage best-effort (a
        comp without one is still a comp). Re-adding an existing ASIN
        refreshes its listing snapshot in place, keeping `source` and
        `sort_index`. One entry or a wizard batch, same shape; each lookup is
        a live Amazon page fetch (5–30 s), so batches run for a while.
        An Audible ASIN is hopped to its Kindle/print sibling (their
        listings carry no blurb) and stored under THAT edition's ASIN — so
        the returned comp's `asin` may differ from what was sent; a book
        with no readable edition at all fails per-ASIN with
        `audiobook_edition`.
        Per-ASIN failures don't fail the batch: `comps` carries what was
        added, `failed` carries an error code per lost ASIN (200 with an
        empty `comps` when every lookup failed individually).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asins]
              properties:
                asins:
                  type: array
                  minItems: 1
                  maxItems: 20
                  description: ASINs or Amazon product URLs (mixed is fine).
                  items:
                    type: string
                source:
                  type: string
                  enum: [manual, found]
                  default: manual
                  description: >
                    How the author got this comp — hand-entered or accepted
                    from the find-comps wizard. Display-only afterwards.
      responses:
        "201":
          description: At least one comp added or refreshed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompsAddResult"
        "200":
          description: Every per-ASIN lookup failed; see `failed`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompsAddResult"
        "400":
          description: >
            Malformed body (`invalid_json`/`invalid_body`), or an entry that
            is neither an ASIN nor an Amazon product URL (`invalid_asin`,
            with the offending entry echoed as `asin`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/projects/{id}/comps/search:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Glamour]
      operationId: searchComps
      summary: Amazon search for comp candidates (one live Rainforest search).
      description: >
        Backs the manual-entry picker (title typed instead of an ASIN) and
        the wizard's title→ASIN resolution. Returns up to 10 candidates:
        sponsored hits dropped, and audiobook hits re-pointed at their
        Kindle/print sibling (Audible often owns the top slot for a title
        but its listings are stale) — the candidate `asin` is the readable
        edition. Nothing is stored until the chosen ASIN is POSTed to
        /comps. Routinely 10–30 s.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query:
                  type: string
                  minLength: 1
                  maxLength: 200
      responses:
        "200":
          description: Candidates, best match first.
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      $ref: "#/components/schemas/CompCandidate"
        "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"
  /api/projects/{id}/comps/angles:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Glamour]
      operationId: findCompAngles
      summary: >
        Propose three ways to sell this book, each with three comparable
        titles (the wizard's first step).
      description: >
        Reads the opening chapter(s), the first half of the outline and the
        story bible, and returns three *angles* — a shelf the book could lead
        on, the promise that shelf's reader buys, who they are, what the
        angle costs, and three comps that already sit there. The half is
        deliberate: comps are chosen against a book's promise, not its
        resolution.


        **No Amazon call happens here** — comps come back as prose (title,
        author, why), and resolving them to ASINs is the caller's next,
        explicit step through /comps/resolve. Nothing is stored; the author's
        chosen angle becomes the project's `positioning_brief`, and
        /comps/shelf digs deeper on the picked shelf.


        Two models, two jobs. An ungrounded judgment pass reads the book and
        decides the three shelves (returned as `book` plus each angle's
        framing); three concurrent grounded searches, one per shelf, then find
        the titles. Roughly a minute on a full-length book.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                outline_fraction:
                  type: number
                  exclusiveMinimum: 0
                  maximum: 1
                  default: 0.5
                  description: >
                    How much of the outline to feed, from the start. Half by
                    default.
      responses:
        "200":
          description: >
            How the book was read, three angles, three comps each, and where
            the inputs came from.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompAnglesResult"
        "400":
          description: Malformed body (`invalid_body`) or a bad project id (`invalid_id`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: >
            The model failed, or answered in a shape that would not parse even
            after a repair pass (`upstream_error`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/comps/shelf:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Glamour]
      operationId: shelfComps
      summary: >
        Dig deeper on the picked shelf — eighteen more books commonly
        associated with the positioning brief's anchors (the wizard's step
        after the angle pick).
      description: >
        Reads the project's `positioning_brief` (saved by the wizard at the
        angle pick — this route takes no body) and returns three sets of six
        real books, each set ordered along a gradient axis the model judges
        most revealing for this shelf (cozy ↔ dark, commercial ↔ literary,
        KU-indie ↔ trad, …). The axes are a generation device and travel as
        provenance; clients render one flat grid. Covers come from the
        keyless catalogues (Apple Books, Open Library), verified on title
        AND author.


        **No Amazon call happens here** — the books are prose with no ASIN;
        picks are resolved through /comps/resolve. One grounded model call,
        ~30 s. Nothing is stored.
      responses:
        "200":
          description: Three axes, six books each, plus what the brief could not say.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompShelfResult"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            The project has no positioning brief yet (`no_brief`) — the shelf
            step digs into a decision the angle pick has not made.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: >
            The model failed, or answered in a shape that would not parse even
            after a repair pass (`upstream_error`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/comps/resolve:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Glamour]
      operationId: resolveComps
      summary: >
        Resolve shelf picks (title + author, no ASIN) into stored board
        comps via Amazon search.
      description: >
        For each book: one live Rainforest search, the best hit verified on
        title AND author (Amazon search always answers *something* — an
        unverified hit is a wrong book on the board), then the same add path
        as POST /comps — listing snapshot, Storage cover, related carousels
        kept in `extras.related`. Each book is two live Amazon fetches
        (5–30 s each), so the batch is capped at 6; clients chunk a bigger
        selection and show progress.


        Per-book failures don't fail the batch: `failed` carries
        title/author + error code each (`no_match` when nothing verified),
        and all-failed still returns 200. Only invalid input, a missing
        project, or a missing API key fail the request outright.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [books]
              properties:
                books:
                  type: array
                  minItems: 1
                  maxItems: 6
                  items:
                    type: object
                    required: [title]
                    properties:
                      title:
                        type: string
                        minLength: 1
                        maxLength: 300
                      author:
                        type: string
                        maxLength: 300
                        default: ""
      responses:
        "201":
          description: At least one book resolved and stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompResolveResult"
        "200":
          description: Every book failed to resolve; see `failed`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompResolveResult"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/projects/{id}/comps/more:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Glamour]
      operationId: moreComps
      summary: >
        Offer the next ring of comps from the board's stored related-book
        carousels, pruned against the positioning brief.
      description: >
        Pools `extras.related` across the board's comps (kept at add time —
        no Rainforest call happens here), drops what is already on the
        board, cross-counts the rest (`appears_in`), and has a cheap model
        prune the pool against the positioning brief and the current board.
        Up to 18 candidates come back, strongest cross-validation first;
        `already_comp` is always false here by construction. Nothing is
        stored — picks go through POST /comps. An empty list is an ordinary
        result (a fresh board has no carousels yet), not an error; the
        model pass failing degrades to the unpruned pool rather than a 502.
      responses:
        "200":
          description: Candidates worth offering, best first (possibly none).
          content:
            application/json:
              schema:
                type: object
                required: [candidates]
                properties:
                  candidates:
                    type: array
                    items:
                      $ref: "#/components/schemas/ExpandCandidate"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          $ref: "#/components/responses/UpstreamError"
  /api/projects/{id}/comps/cover-brief:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Glamour]
      operationId: getCoverBrief
      summary: >
        The stored cover takeaway (or null) with staleness, plus the curated
        artist directory.
      description: >
        Reads only — no model call, no plan gate. `stale` is server-computed:
        true when the covered board or the artist directory changed since the
        takeaway was generated. The positioning brief is deliberately not an
        input — the takeaway is a reading of the cover images alone, so
        picking a different angle never flags it stale. The directory is
        global product data curated from the backoffice; it rides along so
        the cover page can show it even before a takeaway exists.
      responses:
        "200":
          description: The takeaway (null when never generated) and the directory.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CoverBriefEnvelope"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Glamour]
      operationId: generateCoverBrief
      summary: >
        (Re)generate the cover takeaway — one vision model call that looks at
        the board's stored comp covers.
      description: >
        Glamour's cover step never generates a cover. The model is shown the
        actual stored cover images of the board's comps (board order, up to
        12) and writes the brief the author hands to a human artist — what
        kind of artist to look for, the covers' shared elements, style,
        colors, and other notes, each citing covers by title. It is
        deliberately NOT told the shelf: the images are the whole input, no
        genre may be named or reasoned from, and a takeaway where no point
        cites a cover is rejected rather than stored. Artist picks may only
        name entries of the curated directory; anything else is dropped
        server-side. Cached against a hash of (covered board, artist
        directory): a fresh cache returns without a model call or a quota
        spend, and `force: true` is the author's explicit Update.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  default: false
      responses:
        "200":
          description: The takeaway (fresh, or still-fresh cached) and the directory.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CoverBriefEnvelope"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            No comp on the board has a stored cover (`no_covers`) — there is
            nothing to look at yet.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: >
            The model failed, answered in a shape that would not parse,
            returned a takeaway whose points cited no cover at all, or no
            stored cover could be read back (`upstream_error`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/comps/{compId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/CompId"
    delete:
      tags: [Glamour]
      operationId: deleteComp
      summary: >
        Remove a comp from the board (hard delete; re-addable by ASIN).
        Its stored cover is cleaned up best-effort.
      responses:
        "204":
          description: Comp removed.
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/comps/{compId}/cover:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/CompId"
    get:
      tags: [Glamour]
      operationId: getCompCover
      summary: Stream a comp's stored cover image.
      description: >
        Covers are copied into Storage at add time — never hotlinked from
        Amazon's CDN — and this is the only URL clients see for one (it is
        the `cover_url` on the Comp shape). 404 when the comp has no stored
        cover.


        **No session required** — see `downloadProjectAsset`. The bytes are a
        published retail cover; what the id protects is only which books this
        account picked as comps.
      security: []
      parameters:
        - $ref: "#/components/parameters/ImageVersion"
      responses:
        "200":
          description: >
            The cover bytes. Cached immutably when `?v=` is present, one day
            otherwise.
          content:
            image/*:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "502":
          description: The cover exists but Storage couldn't serve it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/pitch:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Glamour]
      operationId: listPitches
      summary: >
        The project's pitch rows (blurb, query letter), with cached guidance
        and analysis.
      description: >
        Rows are created lazily by the save/guidance/analyze routes, so a
        project that never opened Pitch returns an empty list. Staleness is
        server-computed on read: `guidance_stale` when the positioning brief
        or the comp board changed since the sheet was generated,
        `analysis_stale` when the draft changed since the last run.
      responses:
        "200":
          description: Every pitch row the project has (possibly none).
          content:
            application/json:
              schema:
                type: object
                required: [pitches]
                properties:
                  pitches:
                    type: array
                    items:
                      $ref: "#/components/schemas/Pitch"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/pitch/{kind}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/PitchKind"
    put:
      tags: [Glamour]
      operationId: savePitch
      summary: Save the author's draft (the autosave target). Upserts.
      description: >
        The draft is the author's own writing — Pitch never generates pitch
        copy, so this is the only path that writes `content`. No model call,
        no plan gate.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  maxLength: 10000
      responses:
        "200":
          description: The saved row.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PitchEnvelope"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/projects/{id}/pitch/{kind}/guidance:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/PitchKind"
    post:
      tags: [Glamour]
      operationId: generatePitchGuidance
      summary: >
        (Re)generate the guidance sheet — the format primer, tips, and shelf
        vocabulary shown beside the pitch editor.
      description: >
        One ungrounded model call that reads the comp board's stored blurbs
        as its corpus — the shape, length and vocabulary of the sheet are
        findings about those specific blurbs, not conventions of the shelf
        the brief names, and every tip cites the comps it was read off. The
        brief and the book's own evidence (opening chapter, outline half,
        story bible) are the checks on it. Cached against a hash of brief +
        board: a fresh cache returns without a model call or a quota spend,
        and `force: true` is the author's explicit Update — the only path
        that re-reads the current book. The sheet contains no pitch copy —
        tips are directives and questions; the author does all the writing.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  default: false
      responses:
        "200":
          description: The row with a fresh (or still-fresh cached) sheet.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PitchEnvelope"
        "400":
          $ref: "#/components/responses/InvalidId"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            Either the project has no positioning brief yet (`no_brief`) —
            there is no shelf to write guidance for; pick an angle first —
            or no comp on the board carries blurb text (`no_comp_blurbs`).
            The sheet is a reading of those blurbs, so a board without any
            is no input at all: refresh or replace the comps rather than
            retrying. Checked before the spend, since the model would
            otherwise answer confidently off the shelf label alone.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: >
            The model failed, answered in a shape that would not parse, or
            returned a sheet whose tips cited no comp at all — all
            `upstream_error`, all worth retrying.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/pitch/{kind}/analyze:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/PitchKind"
    post:
      tags: [Glamour]
      operationId: analyzePitch
      summary: >
        Read the author's draft against her shelf — strengths, then
        actionable moves, each citing her own board comps.
      description: >
        The body carries the draft and the route saves it first, so the
        analysis never runs on stale text. One ungrounded model call over the
        draft, the positioning brief, the board's stored blurbs, and the
        book's own evidence (opening chapter, outline half, story bible) —
        the last so every suggested move is deliverable by the book as
        written, never a borrowed selling point. Display-only: the result
        contains no rewritten copy and there is no apply path. The stamp
        afterwards touches only the analysis columns, so typing during the
        model call is never rolled back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 10000
      responses:
        "200":
          description: The row with the fresh analysis.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PitchEnvelope"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            The project has no positioning brief yet (`no_brief`) — there is
            no shelf to analyze against; pick an angle first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "422":
          description: The draft is empty (`empty_draft`) — nothing to analyze.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: >
            The model failed or answered in a shape that would not parse
            (`upstream_error`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/content-pull:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Glamour]
      operationId: getContentPull
      summary: >
        Everything the content-pull page shows: the material pulled from the
        manuscript, the last hook set, and the author's kept hooks.
      description: >
        Reads only — no plan gate and no quota spend, so opening the page is
        free. `changed_count` is how many chapters a run would actually
        re-read right now, computed with the same diff the pipeline uses, so
        the Update control cannot claim a different number than the run does.
        A chapter that was renamed or moved is NOT changed; only a content
        edit is.


        The pull itself is written by the `content_pull` job (start it through
        POST /jobs). A pull stored against an older format reads as absent
        here, which is what makes the page rebuild it rather than draw half a
        shape.
      responses:
        "200":
          description: The content pull, with staleness resolved.
          content:
            application/json:
              schema:
                type: object
                required: [content_pull]
                properties:
                  content_pull:
                    $ref: "#/components/schemas/ContentPull"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/projects/{id}/content-pull/hooks:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    post:
      tags: [Glamour]
      operationId: generateMarketingHooks
      summary: >
        Write marketing hooks from the pulled material, the positioning brief
        and the board.
      description: >
        One ungrounded model call over the CACHED pull — this route never
        starts the read and never generates one, so a project with no pull is
        refused with `no_pull` rather than made to wait minutes. Start the
        `content_pull` job first.


        Cached against a sha of (pull + brief + comps + blurb + the author's
        saved hooks): a matching key returns without a model call or a quota
        spend. The saved hooks are an input on purpose — keeping a hook is a
        taste signal the next run should feel.


        The author's blurb is OPTIONAL, unlike on the pitch sheet: hooks come
        from the book and the board, and this surface is reachable before a
        word of blurb exists.


        Every returned hook cites a real pulled item; the citation is resolved
        server-side and a hook citing nothing that resolves is dropped. A run
        where nothing resolves is `upstream_error`, not an empty list.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  description: Regenerate even on a cache hit.
      responses:
        "200":
          description: The hooks, freshly written or served from cache.
          content:
            application/json:
              schema:
                type: object
                required: [hooks]
                properties:
                  hooks:
                    type: array
                    items:
                      $ref: "#/components/schemas/GeneratedHook"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: >
            The plan's allowance for marketing hooks is spent
            (`quota_exceeded`). The body carries the quota snapshot the
            upgrade modal reads.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            There is nothing to write hooks from yet (`no_pull`) — run the
            content pull first; or the project has no positioning brief
            (`no_brief`) — pick an angle first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: >
            The model failed, answered in a shape that would not parse, or
            cited nothing real (`upstream_error`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
  /api/projects/{id}/content-pull/hooks/saved:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Glamour]
      operationId: listSavedHooks
      summary: The author's kept hooks, in her order.
      description: >
        Every kept hook, whoever first wrote it. There is deliberately no
        filter by `source`: a generated hook the author kept is as much a
        statement of her taste as one she typed, and both feed the next
        generation as examples.
      responses:
        "200":
          description: The kept hooks.
          content:
            application/json:
              schema:
                type: object
                required: [hooks]
                properties:
                  hooks:
                    type: array
                    items:
                      $ref: "#/components/schemas/SavedHook"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Glamour]
      operationId: saveHook
      summary: Keep a hook — one the author wrote, or one off the generated list.
      description: >
        Saving is authoring, not generating: no plan gate and no quota spend.
        `source` records provenance for display and decides nothing, which is
        why it is accepted from the client — there is nothing here to spoof.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  maxLength: 400
                source:
                  type: string
                  enum: [author, generated]
                  default: author
                evidence:
                  type: string
                  description: The pulled quote or scene this hook came from.
                chapter:
                  type: string
      responses:
        "201":
          description: The kept hook.
          content:
            application/json:
              schema:
                type: object
                required: [hook]
                properties:
                  hook:
                    $ref: "#/components/schemas/SavedHook"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/projects/{id}/content-pull/hooks/saved/{hookId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - name: hookId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      tags: [Glamour]
      operationId: updateSavedHook
      summary: Edit a kept hook in place, or move it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text:
                  type: string
                  maxLength: 400
                sort_index:
                  type: integer
      responses:
        "200":
          description: The updated hook.
          content:
            application/json:
              schema:
                type: object
                required: [hook]
                properties:
                  hook:
                    $ref: "#/components/schemas/SavedHook"
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      tags: [Glamour]
      operationId: deleteSavedHook
      summary: Forget a kept hook.
      responses:
        "204":
          description: Deleted.
        "400":
          $ref: "#/components/responses/InvalidJsonOrBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /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 opened it yet. Unlike the
        generated `/outline` and `/ai-outline` caches this is a hand-edited
        document: the client seeds it from the manuscript's chapter list on
        first open (no model call) 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 second value). The
        subprotocol list is used because the `WebSocket` constructor cannot
        set headers.

        On **cookie transport** the second subprotocol is the literal
        `gk-cookie` instead, and the browser attaches `gk_access` to the
        upgrade itself. Because `Sec-Fetch-Site` is not sent by every browser
        on an upgrade, the origin proof here is the `Origin` allowlist (always
        sent on a WS handshake, and already enforced before the upgrade is
        accepted); `Sec-Fetch-Site` is checked additionally when present.


        **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` plus either
            `bearer.<access_token>` or, on cookie transport, `gk-cookie`.
          schema:
            type: string
      responses:
        "101":
          description: Switching Protocols. The WebSocket is open.
        "400":
          description: The `ghostkey-v1` subprotocol was missing or unknown.
        "401":
          description: >
            Neither auth subprotocol was usable — no `bearer.*` value, or
            `gk-cookie` without an acceptable `gk_access` cookie.
  /api/story-templates:
    get:
      tags: [Projects]
      operationId: listStoryTemplates
      summary: The story structures a map can be built against.
      description: >
        The catalog of story templates - Story Grid, three-act, the
        fifteen-beat sheet, the hero's journey, and the genre beat sheets
        (romance, mystery, thriller, heroic and epic fantasy). A template
        declares structure only: the beats a tradition asks for, in order,
        with a line of craft guidance each. Not project-scoped, since a
        template is a structure rather than story content, but user-scoped
        all the same - the list will carry the author's own templates
        alongside the built-in catalog, distinguished by `origin`.
      responses:
        "200":
          description: The templates available to this user, catalog order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StoryTemplatesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /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
    SeriesId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ProjectId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DraftId:
      name: draftId
      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
    TemplateId:
      name: templateId
      in: path
      required: true
      description: A story template id (`StoryTemplate.id`).
      schema:
        type: string
        maxLength: 200
    CompId:
      name: compId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    UserId:
      name: userId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ArtistId:
      name: artistId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    PitchKind:
      name: kind
      in: path
      required: true
      schema:
        type: string
        enum: [blurb, query_letter]
    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
    ImageVersion:
      name: v
      in: query
      required: false
      description: >
        Any value that changes when the image does — clients send the asset id
        or the ETag. Its presence (not its value) is what opts the response
        into `Cache-Control: immutable`; the bare path keeps revalidating,
        because it is stable across cover changes.
      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 }
    Forbidden:
      description: Valid session, but the account is not allowed on this surface.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            forbidden:
              value: { error: forbidden }
    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 }
    BillingNotConfigured:
      description: >
        Billing is not configured on this deployment (Stripe secrets unset).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            billing_not_configured:
              value: { error: billing_not_configured }
    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:
    AdminIdentity:
      type: object
      required: [id, email]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
    PlanGrant:
      type: object
      required: [plan, expires_at, note, granted_by, created_at]
      description: >
        A plan an operator handed out. Not a subscription: it has no Stripe
        object behind it, bills nothing and renews never. It is reconciled with
        any subscription the account also has, higher tier winning, before any
        gate sees a plan.
      properties:
        plan:
          $ref: "#/components/schemas/Plan"
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: Null means open-ended - it lasts until someone revokes it.
        note:
          type: string
          nullable: true
          description: The operator's own words on why. Admin-only; never shown to the author.
        granted_by:
          type: string
          format: email
          nullable: true
          description: The admin who issued it, or null once that account is gone.
        created_at:
          type: string
          format: date-time
    PlanGrantInput:
      type: object
      required: [plan, expires_at]
      properties:
        plan:
          type: string
          enum: [basic, standard, pro]
          description: >
            `free` is deliberately absent - the higher tier wins, so a free
            grant could never take anything away. Use DELETE.
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: >
            When it ends, or null for open-ended. A date rather than a
            duration, so the server stores what it was told rather than what it
            computed from a clock the caller could not see. Must be in the
            future.
        note:
          type: string
          maxLength: 500
          nullable: true
    AdminAccount:
      type: object
      required:
        [id, email, created_at, email_verified, is_admin, plan, subscription, grant]
      description: >
        One account's billing standing, as the backoffice sees it. `plan` is
        what is in force; `subscription` and `grant` explain it and may both be
        null while `plan` is not.
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        created_at:
          type: string
          format: date-time
        email_verified:
          type: boolean
        is_admin:
          type: boolean
        plan:
          $ref: "#/components/schemas/Plan"
        subscription:
          allOf:
            - $ref: "#/components/schemas/SubscriptionRecord"
          nullable: true
          description: >
            What they pay for, if anything. Never reconciled against Stripe on
            this route - a search must not fire a Stripe call per row - so a
            subscription this server has not heard about yet shows as null here
            while the author's own account screen would recover it.
        grant:
          allOf:
            - $ref: "#/components/schemas/PlanGrant"
          nullable: true
    AdminAccountEnvelope:
      type: object
      required: [account]
      properties:
        account:
          allOf:
            - $ref: "#/components/schemas/AdminAccount"
          nullable: true
    AdminAccountPage:
      type: object
      required: [accounts, limit]
      properties:
        accounts:
          type: array
          items:
            $ref: "#/components/schemas/AdminAccount"
        limit:
          type: integer
          description: >
            The cap the result was taken under, so a client can say "the first
            N of possibly more" rather than implying it found them all.
    ModelRate:
      type: object
      required:
        [model, input_per_mtok, cache_read_per_mtok, cache_write_per_mtok,
         output_per_mtok, note, updated_at]
      properties:
        model:
          type: string
          description: Provider model slug — matches `llm_usage.model`.
        input_per_mtok:
          type: number
          description: US dollars per million uncached input tokens.
        cache_read_per_mtok:
          type: number
        cache_write_per_mtok:
          type: number
          description: Anthropic only; 0 for implicit-cache providers.
        output_per_mtok:
          type: number
          description: >
            Applies to reasoning/thinking tokens too — they bill at the output
            rate and are counted in `output_tokens`.
        note:
          type: string
          nullable: true
          description: >
            Where the number came from. Seeded rows say "unverified
            placeholder"; clearing it marks the rate as checked.
        updated_at:
          type: string
    ModelRateInput:
      type: object
      required: [model, input_per_mtok, output_per_mtok]
      properties:
        model:
          type: string
          maxLength: 128
        input_per_mtok:
          type: number
          minimum: 0
          maximum: 10000
        cache_read_per_mtok:
          type: number
          minimum: 0
          maximum: 10000
          default: 0
        cache_write_per_mtok:
          type: number
          minimum: 0
          maximum: 10000
          default: 0
        output_per_mtok:
          type: number
          minimum: 0
          maximum: 10000
        note:
          type: string
          maxLength: 200
          nullable: true
    ModelRates:
      type: object
      required: [rates, unpriced_models]
      properties:
        rates:
          type: array
          items:
            $ref: "#/components/schemas/ModelRate"
        unpriced_models:
          type: array
          description: Slugs seen in `llm_usage` with no rate row.
          items:
            type: string
    SpendTotals:
      type: object
      description: >
        What one group of `llm_usage` rows added up to. Grouped by model, by
        feature or by account — the totals are the same fields either way, so
        the three reports are read the same way and priced the same way.
      required:
        [calls, input_tokens, cache_read_tokens, cache_write_tokens,
         output_tokens, cost_micros, unpriced_calls]
      properties:
        calls:
          type: integer
        input_tokens:
          type: integer
          description: Uncached input only — cache reads are their own bucket.
        cache_read_tokens:
          type: integer
        cache_write_tokens:
          type: integer
          description: >
            Anthropic only; implicit-cache providers never bill a write and
            report 0 here.
        output_tokens:
          type: integer
          description: >
            Includes reasoning/thinking tokens, which bill at the output rate
            and on a reasoning model can exceed the visible answer.
        cost_micros:
          type: integer
          nullable: true
          description: >
            Millionths of a dollar. Null = no rate held for anything in the
            group, which is "unpriced" and not "free".
        unpriced_calls:
          type: integer
    ModelSpend:
      allOf:
        - $ref: "#/components/schemas/SpendTotals"
        - type: object
          required: [model]
          properties:
            model:
              type: string
              description: Provider model slug, the key the rate table is keyed by.
      description: One model's total within a feature.
    FeatureSpend:
      allOf:
        - $ref: "#/components/schemas/SpendTotals"
        - type: object
          required: [feature, models]
          properties:
            feature:
              type: string
              description: >
                Caller-supplied label. Server-side callers use the job-kind
                vocabulary (`continuity`, `world_bible`, …); client-side ones
                send it on the agent request. Unlabelled agent traffic lands in
                `agent_unattributed`.
            models:
              type: array
              items:
                $ref: "#/components/schemas/ModelSpend"
      description: One feature's total, plus its per-model breakdown.
    SpendReport:
      type: object
      required: [days, since, total_cost_micros, total_unpriced_calls, features]
      properties:
        days:
          type: integer
        since:
          type: string
          description: Start of the window, as the database rendered it.
        total_cost_micros:
          type: integer
          nullable: true
        total_unpriced_calls:
          type: integer
          description: >
            Calls whose model has no rate, so their cost is absent from the
            total. `GET /api/admin/rates` names which models those are.
        features:
          type: array
          items:
            $ref: "#/components/schemas/FeatureSpend"
    AccountSpend:
      allOf:
        - $ref: "#/components/schemas/SpendTotals"
        - type: object
          required: [owner_id, email]
          properties:
            owner_id:
              type: string
              format: uuid
              description: >
                Pass as `?owner=` to `GET /api/admin/usage` for this account's
                per-feature breakdown.
            email:
              type: string
      description: One account's total across every feature and model it ran.
    AccountSpendReport:
      type: object
      required:
        [days, since, total_cost_micros, total_unpriced_calls, account_count,
         limit, accounts]
      properties:
        days:
          type: integer
        since:
          type: string
          description: Start of the window, as the database rendered it.
        total_cost_micros:
          type: integer
          nullable: true
          description: >
            Across every account in the window, not just the listed ones. Null
            only when nothing in the window was priced; a window in which
            nothing ran at all is 0.
        total_unpriced_calls:
          type: integer
        account_count:
          type: integer
          description: Accounts that ran at least one model call in the window.
        limit:
          type: integer
          description: >
            How many accounts this response may list. A full list means "at
            least this many spent something", never "this is all of them" —
            compare against `account_count`.
        accounts:
          type: array
          description: Heaviest spenders first; unpriced accounts last.
          items:
            $ref: "#/components/schemas/AccountSpend"
    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
            attribution:
              allOf:
                - $ref: "#/components/schemas/Attribution"
              description: >
                Optional. Reported with the registration as a server-side
                conversion event and used for nothing else — it decides no part
                of the account, and a client that sends none still registers.
                Only a client with a page of its own can fill the two cookies;
                the desktop build has none and sends neither.
    AuthSession:
      type: object
      required: [access_token, refresh_token, expires_at, user]
      description: >
        On cookie transport `access_token` and `refresh_token` are opaque
        per-response markers rather than credentials — the real tokens are in
        `Set-Cookie`. See the "Auth transport" note in the API description.
      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, email_verified]
          properties:
            id:
              type: string
              format: uuid
            email:
              type: string
              format: email
            email_verified:
              type: boolean
              description: >
                Whether the address has been confirmed. Unverified accounts
                sign in and use the API like any other — a client surfaces the
                state (the account screen shows an "Unverified" badge) rather
                than blocking on it. The address is confirmed by clicking the
                link in the verification email (which lands on
                `/api/auth/verify-email`); a signed-in client can ask for a
                fresh one with `/api/auth/resend-verification`.
    Ok:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
          const: true
    ResendVerificationAccepted:
      type: object
      required: [retry_after]
      properties:
        retry_after:
          type: integer
          minimum: 0
          description: >
            Seconds before another resend is accepted. The server owns this
            number — count it down rather than hardcoding the current 60, so a
            client's lock never disagrees with what the route will actually do.
    ResendVerificationCooldown:
      type: object
      required: [error, retry_after]
      properties:
        error:
          type: string
          const: rate_limited
        retry_after:
          type: integer
          minimum: 0
          description: Seconds left before another resend is accepted.
    BillingRedirect:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: Stripe-hosted page to open in the system browser.
    Attribution:
      type: object
      description: >
        What prompted a registration, a subscribe, or a plan switch. Reported
        to the ad platform as a server-side conversion event and used for
        nothing else — it decides no access, no price and no plan, and every
        field is optional, so a client that knows none of it can still sign up
        or buy.

        Attached to the Stripe Checkout Session's metadata on the way out and
        read back off it when the webhook confirms the money, because the
        checkout that starts here is confirmed minutes later with no request of
        ours in between.
      properties:
        source:
          type: string
          maxLength: 64
          pattern: "^[a-z0-9_.:-]+$"
          description: >
            The capability id of the padlock the author hit
            (`phantom.continuity`, `mara.entity_portrait`), or the screen they
            chose a plan on when no padlock was involved (`account`,
            `landing`). Answers which locked feature actually sells the
            product — nothing else can reconstruct that after the fact. A
            registration has no padlock behind it and sends `signup`.
          example: phantom.continuity
        fbp:
          type: string
          maxLength: 255
          description: >
            The browser's `_fbp` cookie. Forwarded in the body because it
            cannot arrive any other way: it is set on the web app's origin and
            this API is a different one, so it is never sent as a cookie, and
            the desktop build has no page to set it in at all.
        fbc:
          type: string
          maxLength: 255
          description: The browser's `_fbc` cookie (the click id of the ad that led here).
        ph:
          type: string
          maxLength: 255
          description: >
            The browser's PostHog anonymous id — the identity its cookieless
            pageviews were captured under. Sent by the web build at signup so
            the server can merge that session into the new account's analytics
            person; the id lives only in page memory (never a cookie), so it
            links one visit to the account it created and nothing else. The
            desktop and mobile builds carry no analytics tag and send nothing.
        consent:
          type: string
          enum: [granted, denied]
          description: >
            The visitor's answer to the marketing-measurement question, as shown
            on the web origin. `denied` suppresses the conversion event outright
            — the server reports nothing to the ad platform for this action.

            Absent means *not asked*, which is not the same as a refusal and is
            the permanent state of the desktop build: it carries no advertising
            tag and has no banner, so it sends no decision and is reported as it
            always was. Only an explicit `denied` silences an event.

            It travels on this object rather than as a header or a session flag
            because the event it governs can fire long after the request that
            carries it — a checkout is confirmed by a webhook minutes later,
            with nothing of ours in between — so it has to be persisted with the
            rest of the attribution and read back off the Stripe metadata.
          example: granted
    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
    Series:
      description: >
        A named set of books. Every project belongs to exactly one: a book
        created on its own gets an `implicit` series named after it, which the
        author never sees until they name it or add a second book. That is not
        cosmetic — the world bible's AUTHOR layer (renames, notes, appearance,
        portraits, hand-added entities) lives on the series, so a nullable
        membership would give that layer two homes and a merge step the first
        time a book joined a series.
      type: object
      required:
        - id
        - owner_id
        - name
        - implicit
        - sort_index
        - book_count
        - deleted_at
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        owner_id: { type: string, format: uuid }
        name: { type: string }
        implicit:
          type: boolean
          description: >
            True until the author names it. Rendered as a bare project row when
            it also has a single book.
        sort_index: { type: integer }
        book_count:
          type: integer
          description: Live, non-deleted projects in this series.
        deleted_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SeriesCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
    SeriesPatch:
      type: object
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Setting a name also flips `implicit` to false.
        sort_index:
          type: integer
          minimum: 0
          maximum: 100000
    Project:
      type: object
      required:
        - id
        - owner_id
        - folder_id
        - series_id
        - series_index
        - name
        - title
        - description
        - author
        - format_version
        - cover_filename
        - cover_thumbnail
        - saved_prompts
        - editing_plan
        - freewrite_folder
        - positioning_brief
        - active_draft_id
        - 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
        series_id:
          description: >
            The series this book belongs to. Never null — a book created on its
            own gets an implicit series of one. Also the scope its world-bible
            entity portraits are stored under.
          type: string
          format: uuid
        series_index:
          description: Reading order within the series (book 1, book 2…).
          type: integer
        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]
        positioning_brief:
          description: >
            Glamour's positioning brief — a short author-editable prose
            description of the book's genre, audience, and market position,
            composed by the find-comps wizard and reused by later Glamour
            features. Null until one is written.
          type: [string, "null"]
        active_draft_id:
          description: >
            The draft currently being written in — the only writable
            manuscript. Every project has one; `chapters` on ProjectDetail,
            exports, jobs and word counts all report this draft alone.
          type: string
          format: uuid
        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` is deliberately absent: chapter ordering belongs to a
        # draft, and is written with PATCH /api/projects/{id}/drafts/{draftId}.
        # Notes are project-level and stay here.
        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
        series_id:
          description: >
            Move this book to another series. The join/leave door, and the only
            one — not nullable, because every book is in a series.


            The world-bible author layer does NOT travel with the book: it
            re-derives against the destination's overrides, and the ones it
            left behind go dormant (which is already what an override matching
            nothing does). Merging them into the destination instead would
            write into an object shared by every other book there. Clients
            should say so at the click.
          type: string
          format: uuid
        series_index:
          description: Reading order within the series.
          type: integer
          minimum: 0
          maximum: 100000
        freewrite_folder:
          description: Freewrite device folder to read drafts from, or null to untie.
          type: [string, "null"]
          enum: [A, B, C, null]
        positioning_brief:
          description: The positioning brief, or null to clear it.
          type: [string, "null"]
          maxLength: 20000
    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
    Draft:
      description: >
        One draft of a book: a named, ordered set of chapter documents. Exactly
        one per project is active and writable.
      type: object
      required:
        [id, name, is_active, chapter_count, word_count, sort_index, outline, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        is_active:
          type: boolean
          description: Whether this is the draft currently being written in.
        chapter_count:
          type: integer
        word_count:
          type: integer
        sort_index:
          type: integer
        outline:
          description: >
            What this draft's kept AI story outline can say about it, or null
            when the draft was never analysed while it was active. Inactive
            drafts keep their outline — nothing regenerates it, because nothing
            reads a frozen manuscript.
          oneOf:
            - type: object
              required: [present, central_relationship]
              properties:
                present:
                  type: boolean
                central_relationship:
                  type: [string, "null"]
            - type: "null"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          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, reference_document_id, updated_at]
      properties:
        id:
          type: string
          format: uuid
        kind:
          $ref: "#/components/schemas/DocumentKind"
        filename:
          type: string
        version:
          description: >
            The document's *content* revision, and the staleness key for every
            derived artifact. Only a change to the prose bumps it — a rename or
            a reference change deliberately does not.
          type: integer
        reference_document_id:
          description: >
            A chapter in another draft of this project that the author chose to
            write this one against; Apparition shows it alongside. Null is the
            normal state.
          type: [string, "null"]
          format: uuid
        updated_at:
          type: string
          format: date-time
    Document:
      allOf:
        - $ref: "#/components/schemas/DocumentSummary"
        - type: object
          required: [project_id, owner_id, draft_id, content, deleted_at, created_at]
          properties:
            project_id:
              type: string
              format: uuid
            owner_id:
              type: string
              format: uuid
            draft_id:
              description: >
                The draft this chapter belongs to. Null for notes, which are
                project-level and shared by every draft.
              type: [string, "null"]
              format: uuid
            content:
              type: string
            deleted_at:
              type: [string, "null"]
              format: date-time
            created_at:
              type: string
              format: date-time
    Asset:
      description: >
        A binary attachment. Scoped to exactly ONE of a project or a series —
        precisely one of `project_id` / `series_id` is non-null. Covers, Séance
        board images and Glamour comps belong to a book; world-bible entity
        portraits belong to a series, because a picture of a character has to
        render from every book in it.


        The scope never reaches storage: keys are `<owner_id>/<asset_id>`, so
        rescoping an asset is one row update and never a copy.
      type: object
      required:
        - id
        - project_id
        - series_id
        - owner_id
        - filename
        - content_type
        - size_bytes
        - etag
        - deleted_at
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        project_id:
          description: Set for a book-scoped asset; null for a series-scoped one.
          type: [string, "null"]
          format: uuid
        series_id:
          description: Set for a series-scoped asset (portraits); null otherwise.
          type: [string, "null"]
          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 }
    Comp:
      type: object
      description: >
        One comp title on Glamour's board. A server-made snapshot of the
        Amazon listing at add time (refreshed on re-add); `extras` keeps the
        listing's market signals verbatim for later Glamour features.
      required:
        - id
        - project_id
        - asin
        - title
        - author
        - blurb
        - source
        - extras
        - sort_index
        - cover_url
        - amazon_url
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        asin: { type: string }
        title: { type: string }
        author: { type: string }
        blurb: { type: string }
        source:
          type: string
          enum: [manual, found]
        extras:
          type: object
          required: [rating, ratings_total, categories_flat, bestsellers_rank, related]
          properties:
            rating: { type: [number, "null"] }
            ratings_total: { type: [integer, "null"] }
            categories_flat: { type: [string, "null"] }
            bestsellers_rank:
              type: array
              items:
                type: object
                required: [category, rank]
                properties:
                  category: { type: string }
                  rank: { type: integer }
            related:
              type: array
              description: >
                Books off the listing's related carousels ("also bought or
                read", "similar to consider", …), kept at add time so
                /comps/more can offer the next ring of comps without another
                Rainforest pass. Empty on comps stored before this field
                existed (re-adding refreshes it). `cover_url` entries are
                direct Amazon CDN links — transient by nature.
              items:
                type: object
                required: [asin, title, author, cover_url]
                properties:
                  asin: { type: string }
                  title: { type: string }
                  author: { type: string }
                  cover_url: { type: [string, "null"] }
        sort_index: { type: integer }
        cover_url:
          description: >
            Server-proxied cover route
            (/api/projects/{id}/comps/{compId}/cover), or null when no cover
            was stored.
          type: [string, "null"]
        amazon_url:
          type: string
          description: >
            The listing on Amazon, minted from the ASIN and the marketplace
            the server resolves against — clients link out with it rather
            than guessing a domain.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CompCandidate:
      type: object
      description: >
        A search/expand candidate — not yet a stored comp. `cover_url` here
        is a direct Amazon CDN link (transient wizard UI only; saved comps
        get their cover copied into Storage).
      required: [asin, title, author, cover_url]
      properties:
        asin: { type: string }
        title: { type: string }
        author: { type: string }
        cover_url: { type: [string, "null"] }
    ExpandCandidate:
      allOf:
        - $ref: "#/components/schemas/CompCandidate"
        - type: object
          required: [appears_in, already_comp]
          properties:
            appears_in:
              type: integer
              description: >
                How many seed books' related carousels surfaced this
                candidate — 2+ is a strong comp signal.
            already_comp:
              type: boolean
              description: Already on this project's comp board.
    CompFailure:
      type: object
      required: [asin, error]
      properties:
        asin: { type: string }
        error:
          type: string
          description: >
            Error code (`upstream_error`, `rate_limited`, `not_found`,
            `audiobook_edition` — an audiobook with no Kindle/print sibling
            to hop to; Audible listings carry no blurb).
    CompsAddResult:
      type: object
      required: [comps, failed]
      properties:
        comps:
          description: Added or refreshed comps, in request order.
          type: array
          items:
            $ref: "#/components/schemas/Comp"
        failed:
          type: array
          items:
            $ref: "#/components/schemas/CompFailure"
    CompShelfBook:
      type: object
      required: [title, author, year, cover_url, cover_source]
      description: >
        A shelf suggestion, as prose — not a listing. No ASIN, because
        nothing was looked up on Amazon to produce it; picks are resolved
        through /comps/resolve.
      properties:
        title: { type: string }
        author: { type: string }
        year:
          type: integer
          nullable: true
          description: Publication year, when the model could establish one.
        match:
          type: string
          enum: [pairing, widened, "n/a"]
          description: >
            Which tier of the pairing rule produced this comp. The finder
            searches the shelf at the book card's `pairing` first and takes
            any match that also clears the anchor bar (`pairing`); it widens
            to the shelf's anchors regardless of pairing only for the slots
            that leaves unfilled (`widened`). `n/a` when the book card named
            no pairing to prefer. Reported by the model rather than inferred:
            a preference it can satisfy silently is one it can skip silently,
            so being made to name the tier is what forces the narrow search.
            An unasserted or unrecognised value is read as `widened` — never
            as a `pairing` claim the model did not make.
        widened_because:
          type: string
          description: >
            On `widened` only: what the narrow, pairing-matching search
            actually turned up and why none of it anchors the shelf — "the FF
            gothics here top out around 300 ratings". Empty string on the
            other two tiers. This is what tells the author whether her shelf
            is genuinely thin or the search was lazy.
        cover_url:
          type: string
          nullable: true
          description: >
            Cover art looked up from the title and author (Apple Books, then
            Open Library, each hit verified on title AND author) after the
            model answered — server-stamped, never model-supplied. A direct
            third-party link; null when neither catalogue has one (clients
            show a text spine — nothing is dropped).
        cover_source:
          type: string
          nullable: true
          enum: [apple_books, open_library]
          description: Which catalogue supplied the cover, so a wrong edition is traceable.
    CompShelfAxis:
      type: object
      required: [axis, books]
      properties:
        axis:
          type: string
          description: >
            The gradient this set runs along — "cozy ↔ dark". Provenance
            only: clients render the books as one flat grid.
        books:
          type: array
          description: Six books, ordered along the axis, one end to the other.
          items:
            $ref: "#/components/schemas/CompShelfBook"
    CompShelfResult:
      type: object
      required: [axes, notes]
      properties:
        axes:
          type: array
          items:
            $ref: "#/components/schemas/CompShelfAxis"
        notes:
          type: string
          description: What the positioning brief could not tell the model.
    CompResolveFailure:
      type: object
      required: [title, author, error]
      properties:
        title: { type: string }
        author: { type: string }
        error:
          type: string
          description: >
            Error code — `no_match` (no verified Amazon hit for the
            title/author pair), or the lookup failure (`upstream_error`,
            `rate_limited`, `audiobook_edition`).
    CompResolveResult:
      type: object
      required: [comps, failed]
      properties:
        comps:
          description: Resolved and stored comps, in request order.
          type: array
          items:
            $ref: "#/components/schemas/Comp"
        failed:
          type: array
          items:
            $ref: "#/components/schemas/CompResolveFailure"
    Pitch:
      type: object
      required:
        [id, project_id, kind, content, guidance, guidance_stale, analysis,
         analysis_stale, created_at, updated_at]
      description: >
        One pitch surface of a project — the author's draft plus the two
        cached model artifacts that help her write it. `content` is the only
        author-written field, and the only one any route writes from client
        input: Pitch never generates pitch copy (AI-detector exposure on
        marketing text is the author's problem, so the product refuses to
        create it), and the guidance/analysis shapes have no field where
        drafted copy could fit.
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        kind:
          type: string
          enum: [blurb, query_letter]
        content:
          type: string
          description: The author's draft. Autosaved via PUT; never model-written.
        guidance:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/PitchGuidance"
        guidance_stale:
          type: boolean
          description: >
            True when the positioning brief or the comp board changed since
            the sheet was generated. Stale guidance still renders — refresh
            is the author's explicit Update, never automatic.
        analysis:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/PitchAnalysis"
        analysis_stale:
          type: boolean
          description: True when the draft changed since the last analysis run.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    PitchEnvelope:
      type: object
      required: [pitch]
      properties:
        pitch:
          $ref: "#/components/schemas/Pitch"
    CoverArtist:
      type: object
      required: [id, name, url, styles, notes, created_at, updated_at]
      description: >
        One entry of the curated cover-artist directory — global product data
        the operator fills in from the backoffice, shown to every author on
        Glamour's cover page.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        url:
          type: string
          description: Portfolio / site link. Empty when only a name is known.
        styles:
          type: string
          description: >
            Free-form — what they're good at ("illustrated fantasy, animal
            characters, gothic"). Feeds the takeaway's artist matching
            verbatim.
        notes: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CoverArtistInput:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 200 }
        url: { type: string, maxLength: 1000 }
        styles: { type: string, maxLength: 500 }
        notes: { type: string, maxLength: 1000 }
    CoverArtistEnvelope:
      type: object
      required: [artist]
      properties:
        artist:
          $ref: "#/components/schemas/CoverArtist"
    CoverPoint:
      type: object
      required: [note, comps]
      properties:
        note: { type: string }
        comps:
          type: array
          items: { type: string }
          description: The covers that show it, by comp title.
    CoverTakeaway:
      type: object
      required:
        [artist_profile, elements, style, colors, notes, artist_picks,
         covers_read]
      description: >
        The takeaway from looking at the board's comp covers — the brief the
        author hands to a human artist. Read off the cover images alone: the
        model is not told the shelf and may not name a genre, so every point
        is something visible on named covers. Nothing in it is a generated
        cover, and artist picks only ever name curated-directory entries
        (resolved and copied at run time, so a later directory edit can't
        dangle them).
      properties:
        artist_profile:
          type: string
          description: >
            What kind of artist or designer to look for — medium, technique,
            portfolio search terms, all describing craft rather than a genre.
            Phrased as either/or options when the covers support more than
            one look ("photo-manipulation or illustrated"); a single
            direction only when they genuinely converge. Never a real-world
            artist's name.
        elements:
          type: array
          items:
            $ref: "#/components/schemas/CoverPoint"
          description: The main things on the covers.
        style:
          type: array
          items:
            $ref: "#/components/schemas/CoverPoint"
        colors:
          type: array
          items:
            $ref: "#/components/schemas/CoverPoint"
        notes:
          type: string
          description: >
            Everything else worth noting — typography, composition, thumbnail
            legibility, series branding.
        artist_picks:
          type: array
          description: >
            Only clear matches out of the curated directory — an artist whose
            hand-written styles line covers the look the takeaway just
            described. Often empty, and empty is a good answer: the directory
            is small, and a stretched pick sends the author to the wrong
            portfolio.
          items:
            type: object
            required: [name, url, styles, why]
            properties:
              name: { type: string }
              url: { type: string }
              styles: { type: string }
              why: { type: string }
        covers_read:
          type: integer
          description: How many covers the model actually saw.
    CoverBrief:
      type: object
      required: [id, project_id, takeaway, stale, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        takeaway:
          $ref: "#/components/schemas/CoverTakeaway"
        stale:
          type: boolean
          description: >
            True when the covered board or the artist directory changed
            since the takeaway was generated — the takeaway's only inputs.
            Stale still renders — refresh is the author's explicit Update,
            never automatic.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CoverBriefEnvelope:
      type: object
      required: [cover_brief, artists]
      properties:
        cover_brief:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/CoverBrief"
        artists:
          type: array
          items:
            $ref: "#/components/schemas/CoverArtist"
    PitchGuidance:
      type: object
      required:
        [format_version, format_label, format_beats, typical_length, tips, vocabulary]
      description: >
        The sheet shown beside the editor before the author has typed a word:
        what the books on her comp board actually do. Read off the board's
        stored blurbs — that corpus, not the brief's shelf label, decides the
        shape, because one shelf holds books pitched nothing alike — with the
        positioning brief and the book's own evidence (opening chapter,
        outline half, story bible) as the checks. Cached on brief + board
        only, so ordinary chapter edits never flag it stale.
      properties:
        format_version:
          type: integer
          description: >
            The sheet shape this row was written against. Bumped server-side
            whenever a stored sheet stops being readable by the code that
            draws it; a sheet stamped with an older one is served as
            `guidance: null`, so the client rebuilds it rather than being
            handed half a shape. Clients read it for nothing else.
        format_label:
          type: string
          description: >
            The primer's title — the micro-niche the board reads as ("Dark
            romantasy blurb"), which is narrower than the shelf the brief
            names.
        format_beats:
          type: array
          items:
            type: object
            required: [name, note]
            properties:
              name:
                type: string
                description: "\"Lead 1's conflict\""
              note:
                type: string
                description: One line on what the beat does for the reader.
        typical_length:
          type: string
          description: >
            The range the board's own blurbs run ("150-250 words"), read off
            their word counts — model-stated, so no client hardcodes a
            genre's conventions.
        tips:
          type: array
          items:
            type: object
            required: [title, body, comps]
            properties:
              title:
                type: string
                description: The tip card's label — "Hook", "Conflict".
              body:
                type: string
                description: >
                  Directive or question about what the author's text should
                  do, naming what the cited comps' blurbs concretely do with
                  the same kind of element. Never contains pitch copy.
              comps:
                type: array
                items: { type: string }
                description: >
                  Which comps the tip was read off, by title — the same
                  evidence rule as PitchMove.comps. A sheet whose tips cite
                  nothing was written off the shelf label and is rejected
                  server-side.
        vocabulary:
          type: array
          items: { type: string }
          description: >
            Trope/keyword phrases the comps' blurbs actually use — the
            shelf's own selling vocabulary.
    PitchAnalysis:
      type: object
      required: [strengths, moves, notes]
      description: >
        One analysis run of the author's draft against her shelf. A briefing,
        not a critique: strengths first, then ranked actionable moves, each
        citing board comps by title. Display-only — there is no apply path,
        and no field carries rewritten copy.
      properties:
        strengths:
          type: array
          items:
            type: object
            required: [note, comps]
            properties:
              note:
                type: string
                description: What the draft already does that the shelf's blurbs do.
              comps:
                type: array
                items: { type: string }
                description: Board comps demonstrating the same thing, by title.
        moves:
          type: array
          items:
            type: object
            required: [lens, comps_do, draft_does, step, comps]
            properties:
              lens:
                type: string
                description: >
                  The model's own label for the dimension — "Hook", "Heat
                  signal" — not an enum, so cross-genre shelves name their
                  own lenses.
              comps_do:
                type: string
                description: What the cited comps' blurbs concretely do here.
              draft_does:
                type: string
                description: Neutral description of the draft — a camera, not a judge.
              step:
                type: string
                description: >
                  The actionable move or question — checked against the
                  book's own evidence, so it is deliverable by the book as
                  written.
              comps:
                type: array
                items: { type: string }
        notes:
          type: string
          description: >
            What the inputs could not tell the model, plus any draft-vs-shelf
            positioning question that belongs to the author.
    CompAngleComp:
      type: object
      required:
        [title, author, year, why, differs, evidence, match, widened_because,
         cover_url, cover_source]
      description: >
        A proposed comparable title, as prose — not a listing. It has no ASIN
        because nothing was looked up on Amazon to produce it.
      properties:
        title: { type: string }
        author: { type: string }
        year:
          type: integer
          nullable: true
          description: Publication year, when the model could establish one.
        why:
          type: string
          description: What this book and that book share, for this angle.
        differs:
          type: string
          description: Where this book is not like it.
        evidence:
          type: string
          description: >
            What the search showed that makes this a current anchor for the
            shelf — publisher or imprint, series, awards, how it charts. The
            field that separates a found book from a plausible one.
        match:
          type: string
          enum: [pairing, widened, "n/a"]
          description: >
            Which tier of the pairing rule produced this comp. The finder
            searches the shelf at the book card's `pairing` first and takes
            any match that also clears the anchor bar (`pairing`); it widens
            to the shelf's anchors regardless of pairing only for the slots
            that leaves unfilled (`widened`). `n/a` when the book card named
            no pairing to prefer. Reported by the model rather than inferred:
            a preference it can satisfy silently is one it can skip silently,
            so being made to name the tier is what forces the narrow search.
            An unasserted or unrecognised value is read as `widened` — never
            as a `pairing` claim the model did not make.
        widened_because:
          type: string
          description: >
            On `widened` only: what the narrow, pairing-matching search
            actually turned up and why none of it anchors the shelf — "the FF
            gothics here top out around 300 ratings". Empty string on the
            other two tiers. This is what tells the author whether her shelf
            is genuinely thin or the search was lazy.
        cover_url:
          type: string
          nullable: true
          description: >
            Cover art looked up from the title and author (Apple Books, then
            Open Library, each hit verified on title AND author) after the
            model answered — server-stamped, never
            model-supplied. A direct third-party link, like a search
            candidate's: these are proposals nobody has kept, so nothing is
            copied into Storage. Null when neither catalogue has one. The
            edition is whatever the catalogue holds, which is not always the
            one selling today — 3b's Amazon lookup supersedes it.
        cover_source:
          type: string
          nullable: true
          enum: [apple_books, open_library]
          description: Which catalogue supplied the cover, so a wrong edition is traceable.
    CompAngle:
      type: object
      required: [lead, category, promise, reader, tropes, heat, cost, adjust, rank_note, comps]
      properties:
        lead:
          type: string
          description: The angle in a phrase — "Dark fantasy with romance".
        category:
          type: string
          description: The Amazon Kindle category path this angle files under.
        promise:
          type: string
          description: The one sentence the cover and blurb would have to make.
        reader: { type: string }
        tropes:
          type: array
          items: { type: string }
          description: The trope/keyword phrases this shelf searches for.
        heat:
          type: string
          description: closed door | warm | open door | explicit.
        cost:
          type: string
          description: Which readers this angle loses. An angle with no cost is a description.
        adjust:
          type: string
          description: >
            What the cover, blurb and keywords must signal for this book to sit
            on this shelf. The comps are genre anchors rather than
            near-identical books, so a gap between the book and the shelf is
            the normal case — this is what closes it.
        rank_note:
          type: string
          description: Why this angle is ranked where it is; the first is the recommendation.
        comps:
          type: array
          items:
            $ref: "#/components/schemas/CompAngleComp"
    CompBookCard:
      type: object
      required: [engine, load_bearing, texture, pairing, tone, heat, shape, beats]
      description: >
        How step one read the book, before any shelf was chosen or any title
        searched for. Surfaced rather than kept internal because it is the
        whole of what the comp search was told about the book: an author
        looking at nine wrong comps needs to see whether the shelves were
        wrong or the reading underneath them was.
      properties:
        engine:
          type: string
          description: >
            The question the reader keeps turning pages to have answered — the
            one thing that is load-bearing while the rest is texture.
        load_bearing:
          type: array
          items: { type: string }
          description: What a comparable title would have to share to be comparable at all.
        texture:
          type: array
          items: { type: string }
          description: >
            What reads as central on the page and means nothing on a shelf — a
            setting, a beloved side cast, a signature object. Passed to the
            comp search as a list of things NOT to shop for. The pairing used
            to live here and no longer does — it has its own field, because
            the finder treats it as a preference rather than as noise.
        pairing:
          type: string
          description: >
            The romantic configuration as a reader filter names it — MF, MM,
            FF, poly, ensemble — or `none` where the book has no romance.
            Neither load-bearing nor texture: the finder searches each shelf
            at this pairing FIRST and widens only for the slots that leaves
            unfilled, so it needs an unambiguous target rather than a pairing
            buried in a prose texture line. Empty when the analyst did not
            state one, which reads downstream as "nothing to prefer".
        tone:
          type: string
          description: Voice and register, read off the prose rather than the outline.
        heat:
          type: string
          description: >
            What is actually on the page, romance-wise. Distinct from an
            angle's `heat`, which is what that shelf's reader is buying.
        shape:
          type: string
          description: POV, tense, scope, cast size, standalone or series.
        beats:
          type: array
          items: { type: string }
          description: The story beats a comparable book would have to share.
    CompAnglesResult:
      type: object
      required: [book, angles, notes, provenance]
      properties:
        book:
          $ref: "#/components/schemas/CompBookCard"
        angles:
          type: array
          items:
            $ref: "#/components/schemas/CompAngle"
        notes:
          type: string
          description: What the inputs could not tell the model.
        provenance:
          type: object
          description: >
            What the model was actually given. Server-stamped, never
            model-supplied: a thin answer is usually a thin *input*, and
            without this that reads as a bad model instead.
          required: [project_title, draft_id, chapter, outline, bible, journal]
          properties:
            project_title: { type: string }
            draft_id: { type: string, format: uuid }
            chapter:
              type: object
              required: [filenames, chars, truncated, total_chapters]
              properties:
                filenames:
                  type: array
                  items: { type: string }
                  description: >
                    The opening documents used, in reading order — more than
                    one when the first is too short to be a voice sample.
                chars: { type: integer }
                truncated: { type: boolean }
                total_chapters: { type: integer }
            outline:
              type: object
              required: [source, total, used, chars, elided]
              properties:
                source:
                  type: string
                  enum: [ai_outline, authored, reverse_outline, none]
                total: { type: integer }
                used: { type: integer }
                chars: { type: integer }
                elided: { type: boolean }
            bible:
              type: object
              required: [present, chars, truncated]
              properties:
                present: { type: boolean }
                chars: { type: integer }
                truncated: { type: boolean }
            journal:
              type: object
              required: [present, chars]
              properties:
                present: { type: boolean }
                chars: { type: integer }
    AuthoredOutline:
      type: object
      required:
        [
          text,
          chapters,
          matched_draft_id,
          broken_at,
          broken_from,
          committed_draft_id,
          changeset,
          changeset_draft_id,
          changeset_at,
        ]
      properties:
        text:
          type: string
          description: The author's outline, markdown. Empty until they write one.
        chapters:
          type: array
          description: >
            The proposed breakdown, in the order it should be written: a
            one-level tree of chapters and the parts of the plan holding them,
            the same shape a draft's chapter tree has. A proposal only — no
            chapter exists until a commit, and the commit builds the new
            draft's folders from these parts.


            **Empty once the proposal has been committed.** The commit spends
            it: those chapters are real now, in the draft named by
            `committed_draft_id`, and that draft's own chapter tree is where
            they are read and edited from. So this array and
            `committed_draft_id` are never both set — the row holds a proposal
            to review, or the draft it became.
          items:
            $ref: "#/components/schemas/AuthoredOutlineEntry"
        matched_draft_id:
          type: string
          format: uuid
          nullable: true
          description: >
            The draft the `match` references point into. Not the active draft
            any more means the matches are stale; re-run the break.
        broken_at:
          type: string
          format: date-time
          nullable: true
          description: When the breakdown was last generated.
        broken_from:
          nullable: true
          description: >
            The plan the last breakdown read. Written only by the
            `chapter_breakdown` job and, like `broken_at`, it survives a
            clear. Decides which edit voids the proposal: changing `text`
            clears a `prose` proposal and leaves a `map` one alone; editing
            that map (PUT /story-maps/{templateId}, or adopt) clears a `map`
            proposal and leaves a `prose` one alone. Null — every row
            written before the field existed — reads as prose.
          allOf:
            - $ref: "#/components/schemas/PlanRef"
        committed_draft_id:
          type: string
          format: uuid
          nullable: true
          description: >
            The draft this row's proposal became, if it was committed. Set by
            the commit, which clears `chapters` in the same write, and cleared
            again by the next breakdown — so it answers for the proposal on the
            row rather than for any commit that ever happened. Non-null with an
            empty `chapters` is the whole of "this plan has been turned into
            chapters; they live in that draft".
        changeset:
          nullable: true
          description: >
            The pending changeset — how the plan CHANGES the book in hand —
            awaiting the author's review. Written by the `chapter_breakdown`
            job instead of a proposal whenever the active draft already holds
            chapters; spent by POST /authored-outline/apply-changeset, which
            writes `chapters` in its place. At most one of `changeset`,
            `chapters` and `committed_draft_id` is meaningfully set: the row
            holds a diff to review, a proposal to review, or the draft the
            proposal became. Cleared by the same edits that void a proposal
            (rewriting the plan it was read from).
          allOf:
            - $ref: "#/components/schemas/Changeset"
        changeset_draft_id:
          type: string
          format: uuid
          nullable: true
          description: >
            The draft the changeset's ops point into. Not the active draft any
            more means the changeset is stale — apply refuses with
            `changeset_stale`; re-run the break.
        changeset_at:
          type: string
          format: date-time
          nullable: true
          description: When the pending changeset was generated.
    Changeset:
      type: object
      required: [summary, ops]
      additionalProperties: true
      description: >
        How the author's plan changes the book that already exists — the
        `chapter_breakdown` job's answer when the draft holds chapters. The
        plan is read as an EDIT to the book: every chapter no op names is
        kept, whole, and the ops list scales with the changes rather than the
        book. The author approves, edits or rejects each op in review;
        applying is deterministic (no model call), so the approved ops are
        exactly what lands in the proposal.
      properties:
        summary:
          type: string
          description: >
            Two to five sentences addressed to the author — the shape of what
            changes and what stays. Rendered as-is, never parsed.
        ops:
          type: array
          items:
            $ref: "#/components/schemas/ChangesetOp"
    ChangesetOp:
      description: >
        One proposed change. `remove` — chapters the plan's story contradicts;
        `add` — a new chapter the plan calls for, placed after an existing
        one (or at the head of the book when `after` is null); `rewrite` — an
        existing stretch retold as ONE new chapter in the first listed
        chapter's place, referencing it (several chapters in one rewrite is a
        merge). Each op carries a stable `id` (review answers key on it) and a
        `reason` addressed to the author.
      oneOf:
        - $ref: "#/components/schemas/ChangesetRemove"
        - $ref: "#/components/schemas/ChangesetAdd"
        - $ref: "#/components/schemas/ChangesetRewrite"
      discriminator:
        propertyName: op
        mapping:
          remove: "#/components/schemas/ChangesetRemove"
          add: "#/components/schemas/ChangesetAdd"
          rewrite: "#/components/schemas/ChangesetRewrite"
    ChangesetChapterRef:
      type: object
      required: [document_id, filename]
      additionalProperties: true
      description: >
        One existing chapter an op points at, resolved to its stable id when
        the changeset was generated — a rename between review and apply
        changes nothing. `words` is frozen at generation time, display only.
      properties:
        document_id:
          type: string
          format: uuid
          description: The chapter, in `changeset_draft_id`'s draft.
        filename: { type: string }
        words: { type: integer }
    ChangesetRemove:
      type: object
      required: [id, op, chapters, reason]
      additionalProperties: true
      properties:
        id: { type: string }
        op:
          type: string
          enum: [remove]
        chapters:
          type: array
          minItems: 1
          description: >
            The chapters this one cause removes, in book order. One cause is
            one op — a dropped act is a single remove — so the author answers
            the decision once.
          items:
            $ref: "#/components/schemas/ChangesetChapterRef"
        reason:
          type: string
          description: >
            What in the plan asks for this, addressed to the author. Rendered
            as-is, never parsed.
    ChangesetAdd:
      type: object
      required: [id, op, title, notes, reason]
      additionalProperties: true
      properties:
        id: { type: string }
        op:
          type: string
          enum: [add]
        title: { type: string }
        notes:
          type: string
          description: >
            What the author needs in front of them to write the chapter.
            Becomes the card's notes on apply, and the chapter's on commit.
        entities:
          type: array
          items:
            $ref: "#/components/schemas/ChapterCastEntry"
        after:
          nullable: true
          description: >
            The existing chapter the new one follows; null means the head of
            the book. An anchor that was itself removed still places the add
            where that chapter used to stand.
          allOf:
            - $ref: "#/components/schemas/ChangesetChapterRef"
        folder:
          type: string
          nullable: true
          description: >
            The folder (part of the plan) the new chapter lands in; null
            inherits the anchor's folder.
        reason: { type: string }
    ChangesetRewrite:
      type: object
      required: [id, op, chapters, title, notes, reason]
      additionalProperties: true
      properties:
        id: { type: string }
        op:
          type: string
          enum: [rewrite]
        chapters:
          type: array
          minItems: 1
          description: >
            The stretch being retold, in book order. Becomes ONE card in the
            first listed chapter's place, referencing it — so several chapters
            here is the plan merging them.
          items:
            $ref: "#/components/schemas/ChangesetChapterRef"
        title: { type: string }
        notes: { type: string }
        entities:
          type: array
          items:
            $ref: "#/components/schemas/ChapterCastEntry"
        reason: { type: string }
    AuthoredOutlineEntry:
      description: >
        One entry of a chapter proposal: a proposed chapter, or a part of the
        plan holding the chapters that came from it. Folders are one level
        deep — a part never holds a part — because the proposal is the shape of
        the `chapter_tree` it commits into, so a commit copies the structure
        rather than deriving one. A flat array of chapters, which is what every
        proposal made before parts existed is, remains valid.
      oneOf:
        - $ref: "#/components/schemas/AuthoredOutlineFolder"
        - $ref: "#/components/schemas/AuthoredOutlineChapter"
    AuthoredOutlineFolder:
      type: object
      required: [name, chapters]
      additionalProperties: true
      description: >
        One part of the plan — an act, a movement, a section — and the proposed
        chapters that sit in it. Committing creates a folder of this name in
        the new draft holding exactly these chapters, in this order.

        Written by the `chapter_breakdown` job when it reads a story map with
        more than one top-level part (the beats' own containers; a map with one
        part divides nothing, so it produces no folders), and by the author
        editing the proposal afterwards. A prose outline has no parts and
        proposes a flat list.
      properties:
        name:
          type: string
          description: >
            The part's title, coerced to the chapter tree's folder rules (no
            control characters, no `/` or `\`, no `..`, no leading `.`, 1–255
            characters) so a proposal can never commit into a folder the author
            is then unable to rename or drop.
        chapters:
          type: array
          items:
            $ref: "#/components/schemas/AuthoredOutlineChapter"
    AuthoredOutlineChapter:
      type: object
      required: [id, title, notes]
      additionalProperties: true
      description: >
        One proposed chapter. The server validates this much and passes
        everything else through verbatim, so the card shape can iterate without
        a server deploy (same contract as ProjectPlan).
      properties:
        id:
          type: string
          description: Stable card id, minted when the breakdown was generated.
        title: { type: string }
        notes:
          type: string
          description: >
            What this chapter is for. Becomes the chapter's notes on commit,
            and is never overwritten afterwards by anything.
        entities:
          type: array
          description: >
            Who and what this chapter is about, as the breakdown read the plan,
            and the author's to correct before they commit. On commit each
            entry is attached to the chapter it was created for in the world
            bible, and any the bible doesn't hold yet are added to the series'
            author layer. Absent on a proposal generated before this field
            existed, which reads as a chapter with no cast.
          items:
            $ref: "#/components/schemas/ChapterCastEntry"
        origin:
          type: string
          enum: [outline, existing, both]
          description: >
            Where this card came from. An outline skips whatever its author
            took for granted — the connective chapter, the quiet beat — and a
            redraft still wants those, so the breakdown proposes the whole new
            manuscript rather than only the parts the outline spells out.
            `outline` — the outline asked for it and nothing written matches;
            `both` — the outline asked for it and an existing chapter covers
            the same ground; `existing` — the outline never mentions it and it
            is carried in from the current draft, so the author can see what
            they didn't say and drop it if the omission was deliberate. An
            `existing` card always carries a `match`, and defaults to being
            kept whole (reference + prose).
        match:
          nullable: true
          $ref: "#/components/schemas/AuthoredOutlineMatch"
        use_as_reference:
          type: boolean
          description: >
            The author's answer to the match. True sets the new chapter's
            `reference_document_id`. A reference is always a person's choice —
            there is deliberately no derived lineage key beside it.
        copy_prose:
          type: boolean
          description: >
            Start the new chapter from the referenced chapter's prose instead of
            empty. Ignored without a reference.
    AuthoredOutlineMatch:
      type: object
      required: [document_id, filename, words, confidence, reason]
      additionalProperties: true
      description: >
        What the `chapter_breakdown` job found when it held this proposed
        chapter against the manuscript. Advisory: the author accepts or rejects
        it, and nothing is written on the strength of it alone.
      properties:
        document_id:
          type: string
          format: uuid
          description: The existing chapter, in `matched_draft_id`'s draft.
        filename: { type: string }
        words: { type: integer }
        confidence:
          type: string
          enum: [strong, possible]
          description: Only sets the default answer; the author decides.
        reason:
          type: string
          description: >
            One line of prose for the author, rendered as-is. Never parsed, and
            nothing branches on it.
    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 written that day. Words added and rewritten count, deletions
            don't, 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 }
    Plan:
      type: string
      enum: [free, basic, standard, pro]
      description: The plan ladder, cheapest first.
    PlanPrice:
      type: object
      required: [plan, amount, currency, interval, trial_days]
      description: >
        One rung and what it costs, derived from the Stripe price the server
        holds the id for. Never a figure typed into this codebase.
      properties:
        plan:
          $ref: "#/components/schemas/Plan"
        amount:
          type: integer
          nullable: true
          description: >
            Minor units, exactly as Stripe reports `unit_amount` — 499 is
            $4.99.

            Null means **unknown, not free**: an unset price id, an id Stripe
            does not recognise, an archived price, or a price with no flat
            amount (tiered or metered). Render no number rather than inferring
            one. The entry plan is the one case where 0 is a fact.
          example: 499
        currency:
          type: string
          nullable: true
          description: ISO 4217, lowercase. Null whenever `amount` is.
          example: usd
        interval:
          type: string
          nullable: true
          description: >
            Billing period of the recurring price (`month`, `year`, …), so no
            client hardcodes "/ month". Null for the entry plan.
          example: month
        trial_days:
          type: integer
          nullable: true
          description: >
            Days of free trial a *first* subscription to this rung starts
            with, or null when it sells without one — never 0, so clients test
            the field rather than compare it. A deployment fact: whether a
            given account still qualifies is
            `SubscriptionSnapshot.trial_eligible`, and the checkout route
            decides for itself either way. Null for the entry plan.
          example: 14
    PlanCatalog:
      type: object
      required: [currency, plans]
      properties:
        currency:
          type: string
          nullable: true
          description: >
            What this deployment sells in — the currency of the cheapest priced
            rung. Null when nothing is priced.

            Stripe pins a customer's currency at their first subscription, so a
            rung priced in a *different* currency is not merely inconsistent:
            it is unreachable for anyone who started elsewhere on the ladder.
            Comparing each plan's `currency` against this one is how a client
            or an operator detects that.
          example: usd
        plans:
          type: array
          description: Every plan on the ladder, cheapest first (PLAN_ORDER).
          items:
            $ref: "#/components/schemas/PlanPrice"
    Capability:
      type: object
      required: [id, label, granted, required_plan]
      description: >
        One surface and whether this plan reaches it. Ids are dotted
        (`<room>.<surface>`, plus `room.<room>` for a whole room) because the
        vocabulary nests and clients group locks by room. A client must not
        hardcode this list — new capabilities lock and label themselves with no
        client deploy.
      properties:
        id:
          type: string
          example: mara.world_bible_generate
        label:
          type: string
          description: Author-facing name, owned by the server.
          example: World bible extraction
        granted:
          type: boolean
        required_plan:
          allOf:
            - $ref: "#/components/schemas/Plan"
          description: The cheapest plan that reaches this surface.
    AccessSnapshot:
      type: object
      required: [plan, capabilities]
      properties:
        plan:
          $ref: "#/components/schemas/Plan"
        capabilities:
          type: array
          items:
            $ref: "#/components/schemas/Capability"
    FlagSnapshot:
      type: object
      required: [flags]
      description: >
        The caller's resolved feature flags. Keys are flag ids — free-form on
        purpose: the vocabulary lives in the server's flags registry, and
        adding a flag must not need a schema change or a client deploy.
        Clients key on ids but never enumerate them.
      properties:
        flags:
          type: object
          additionalProperties:
            type: boolean
    FlagOverride:
      type: object
      required: [user_id, email, enabled]
      description: One account's canary override on a flag.
      properties:
        user_id:
          type: string
          format: uuid
        email:
          type: string
        enabled:
          type: boolean
    AdminFlag:
      type: object
      required: [id, label, default, global, overrides]
      description: >
        One registry flag with its stored state, for the backoffice. `global`
        null means no global row exists and the flag sits at `default`.
      properties:
        id:
          type: string
        label:
          type: string
          description: Operator-facing name, from the registry.
        default:
          type: boolean
          description: The code default — what clearing the global row restores.
        global:
          type: boolean
          nullable: true
        overrides:
          type: array
          items:
            $ref: "#/components/schemas/FlagOverride"
    FlagWriteInput:
      type: object
      required: [enabled]
      description: >
        One row of flag state. Omit `user_id` (or send null) to write the
        global switch; set it to write that account's override. `enabled` null
        clears the row.
      properties:
        enabled:
          type: boolean
          nullable: true
        user_id:
          type: string
          format: uuid
          nullable: true
    FeatureQuota:
      type: object
      required:
        [
          feature,
          label,
          cadence,
          period_end,
          unlimited,
          allowance,
          used,
          extras,
          remaining,
          purchasable,
          upgrade_plan,
          upgrade_allowance,
        ]
      description: >
        One counted feature's standing for the caller's plan. `unlimited`
        features are listed too, with null counts — the client shows a badge
        only when there is a number to show.
      properties:
        feature:
          type: string
          description: >
            The quota feature id. Free-form on purpose: the vocabulary lives in
            the server's billing/quota/policy.ts, and adding a feature must not
            need a client deploy or a schema change. Clients key badges on it
            but must never enumerate it.
          example: reverse_outline
        label:
          type: string
          description: Author-facing name, server-owned. Render this, not the id.
          example: Reverse outline
        cadence:
          type: string
          enum: [billing, weekly]
          description: >
            How often `used` resets. `billing` follows the snapshot's
            top-level `period_end`; `weekly` resets every Monday 00:00 UTC.
            Copy about a feature's reset must branch on this, or a weekly
            counter reads as monthly.
          example: billing
        period_end:
          type: string
          format: date-time
          description: >
            When this feature's own count resets — equal to the snapshot's
            `period_end` for `billing` cadence, the end of the current UTC
            week for `weekly`. Stamped even when unlimited, where it means
            nothing.
        unlimited:
          type: boolean
          description: >
            When true, nothing is counted and every numeric field is null.
          example: false
        allowance:
          type: integer
          nullable: true
          description: >
            Runs included per period, or null when unlimited. Never `Infinity`
            — that is not representable in JSON and would arrive as null with a
            different meaning.
          example: 2
        used:
          type: integer
          description: Runs drawn from the included allowance this period.
          example: 1
        extras:
          type: integer
          description: >
            Purchased runs still unspent. These do not expire with the period,
            and are only drawn from once the allowance is gone.
          example: 0
        remaining:
          type: integer
          nullable: true
          description: >
            `max(0, allowance - used) + extras`, or null when unlimited. This
            is the number a "N left" badge shows.
          example: 1
        purchasable:
          type: boolean
          description: >
            Whether one more can be bought. False when no price is configured
            for the feature, in which case an exhausted feature can only be
            waited out or upgraded past.
          example: true
        upgrade_plan:
          allOf: [$ref: "#/components/schemas/Plan"]
          nullable: true
          description: >
            The cheapest plan that offers strictly more of this feature than
            the caller's, or null when nothing above does (already on the best
            rung for it, or it is unlimited here). This is what an exhausted
            surface offers; null is what turns that prompt from an upsell into
            a plain "resets on <period_end>" notice, so clients must branch on
            it rather than assume there is always something to sell.
        upgrade_allowance:
          type: integer
          nullable: true
          description: >
            What `upgrade_plan` includes per period — null when that plan is
            unlimited for this feature, following the same
            null-means-unlimited convention as `allowance`. Also null, and
            meaningless, when `upgrade_plan` is null: read it only once you
            have an upgrade to describe.
          example: 5
    QuotaSnapshot:
      type: object
      required: [plan, period_end, features]
      properties:
        plan:
          $ref: "#/components/schemas/Plan"
        period_end:
          type: string
          format: date-time
          description: >
            When the current allowance period ends and billing-cadence `used`
            counts reset. Taken from the Stripe subscription's billing window,
            or the calendar month in UTC for an account without a live
            subscription. Weekly-cadence features reset on their own
            `period_end` instead — see `FeatureQuota.cadence`.
        features:
          type: array
          items:
            $ref: "#/components/schemas/FeatureQuota"
    SubscriptionRecord:
      type: object
      required:
        [plan, status, cancel_at_period_end, current_period_start, current_period_end, trial_end]
      description: >
        The Stripe subscription object as the account screen needs it. Its
        `plan` is what the subscription *bills for*, which is not always what
        the account is entitled to — a `past_due` Pro subscription entitles
        nothing. Read `SubscriptionSnapshot.plan` for entitlement.
      properties:
        plan:
          $ref: "#/components/schemas/Plan"
        status:
          type: string
          description: >
            Stripe's subscription status, passed through unmapped
            (`active`, `trialing`, `past_due`, `canceled`, ...). Clients should
            treat an unrecognised value as "see Stripe" rather than branching
            on an exhaustive list they cannot keep current.
          example: active
        cancel_at_period_end:
          type: boolean
          description: >
            True once the author has cancelled. The subscription stays active
            and the plan stays in force until `current_period_end`.
        current_period_start:
          type: string
          format: date-time
        current_period_end:
          type: string
          format: date-time
          description: >
            When the plan renews, or — when `cancel_at_period_end` is true —
            when it lapses to free.
        trial_end:
          type: string
          format: date-time
          nullable: true
    SubscriptionSnapshot:
      type: object
      required: [plan, subscription, grant, trial_eligible]
      properties:
        plan:
          allOf:
            - $ref: "#/components/schemas/Plan"
          description: >
            The plan in force right now — the same value GET /api/billing/access
            reports, from the same source. Carried here so a client never has to
            reconcile two answers to "what am I on" for a lapsed or past-due
            subscription whose record still names a paid plan.
        subscription:
          allOf:
            - $ref: "#/components/schemas/SubscriptionRecord"
          nullable: true
          description: Null for an account that has never subscribed.
        grant:
          allOf:
            - $ref: "#/components/schemas/PlanGrant"
          nullable: true
          description: >
            A plan an operator handed out, when there is one. Carried here for
            the same reason `plan` is: without it an account screen has a Pro
            plan sitting above "no subscription" with no way to say which is
            true.
        trial_eligible:
          type: boolean
          description: >
            True while a checkout from this account would start with the
            rung's `PlanPrice.trial_days`; false once the account has ever held
            a subscription in any state. Display only — the checkout route
            makes the same decision again and a client cannot override it.
    AgentRequest:
      type: object
      required: [system, prompt, surface]
      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.
        feature:
          type: string
          maxLength: 64
          pattern: "^[a-zA-Z0-9_.:-]+$"
          description: >
            Which client feature is spending, for the backoffice cost
            breakdown (GET /api/admin/usage). The server cannot infer this —
            line edit, proofread, chat and comp discovery all arrive as the
            same request shape — so the caller names itself. Omitted or
            malformed values are recorded as `agent_unattributed` rather than
            guessed at, so the report shows the size of its own blind spot.
            A free display label, never validated against a server list — it is
            NOT a gate. See `surface` for that.
        surface:
          type: string
          enum:
            - apparition.proofread
            - apparition.line_edit
            - apparition.style_sheet
            - poltergeist.change_note
            - phantom.chat
            - phantom.chat_title
          description: >
            Which product surface is calling. Required, and validated against a
            closed server-owned set — the plan-access gate for this route.

            Separate from `feature` because the two have opposite trust
            properties: `feature` is a label the client picks freely, this is a
            boundary. The route serves basic surfaces (proofreading, line
            editing, the style sheet, change notes) and standard ones (chat,
            chat titles) on overlapping models, so neither the model id nor a
            free label can tell them apart.

            Checked twice: the caller's plan must grant the surface, and the
            surface must be allowed to run the requested `sources` model. The
            second check is what makes the first unspoofable — every surface
            but chat names the models it may run, so declaring one while
            requesting a different model is `surface_model_mismatch` (400).
            Proofreading, the style sheet and change notes are pinned to a
            single Google model by the manuscript-safety invariant; line
            editing is display-only and carries a short allowlist instead.

            Refusals: 400 `invalid_surface` (absent or unknown), 400
            `surface_model_mismatch`, 403 `plan_insufficient`.
        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. Not combinable with `grounding`.
        grounding:
          type: boolean
          description: >
            Enable Google Search grounding on the default Gemini stream — the
            model searches the live web before answering (Glamour's comp
            discovery). Only valid without `sources`/`messages`
            (`invalid_grounding` otherwise), and not combinable with `json`
            (Gemini rejects the pair): callers wanting structured output
            prompt for JSON and parse it out of the text.
        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
            the manuscript-adjacent jobs (desktop agent modes, both outline
            writers); `gemini-flash-lite` is the rolling Flash Lite
            alias for the tiny-answer utility jobs (chat titles, Poltergeist's
            chapter-change note).
          items:
            type: string
            enum: [opus-4-8, fable-5, sonnet-5, gpt-5-5, gpt-5-6-sol, gemini-3-1-pro, gemini-flash, gemini-flash-lite, 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, fable-5 or sonnet-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.

            The one exception to verbatim passthrough is the **first user
            turn**, which the server also marks as the prompt-cache boundary:
            any `cache_control` a client sets there is replaced by the server's
            own. Clients should not send it — where the cache breaks is the
            server's call, and it may change without a contract change. Later
            turns, assistant turns included, are never rewritten.
          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
    PullQuote:
      type: object
      required: [id, text, why]
      properties:
        id:
          type: string
          description: >
            Short id, unique within its chapter's entry (`q1`, `q2`, …). What a
            hook cites; never shown to the author.
        text:
          type: string
          description: >
            Exact manuscript text. Verified server-side as a substring of the
            chapter (under a typography-folding normalisation) before it is
            stored; a quote that fails is dropped, never repaired.
        why:
          type: string
    PullScene:
      type: object
      required: [id, summary, why]
      properties:
        id:
          type: string
        summary:
          type: string
          description: The moment in the model's words — paraphrase, not a quote.
        why:
          type: string
    PullTrope:
      type: object
      required: [id, name, evidence]
      properties:
        id:
          type: string
        name:
          type: string
        evidence:
          type: string
    PullEntry:
      type: object
      required: [chapter, quotes, scenes, tropes]
      properties:
        chapter:
          type: string
          description: >
            The chapter's filename at the time of reading, relabelled on a
            rename. Identity is `doc_id`, not this.
        quotes:
          type: array
          items:
            $ref: "#/components/schemas/PullQuote"
        scenes:
          type: array
          items:
            $ref: "#/components/schemas/PullScene"
        tropes:
          type: array
          items:
            $ref: "#/components/schemas/PullTrope"
        doc_id:
          type: string
          format: uuid
        doc_version:
          type: integer
          description: >
            The chapter document's `version` when this entry was read — the
            diff key. Only a content write moves it, so a rename or a reorder
            costs no model call.
    StoredPull:
      type: object
      required: [format_version, verify_version, chapters]
      properties:
        format_version:
          type: integer
        verify_version:
          type: integer
          description: >
            Identity of the quote verifier, not of the stored shape. Changing
            how a quote is normalised re-verifies a cached pull for free rather
            than forcing a re-read.
        chapters:
          type: array
          description: One entry per chapter, in reading order.
          items:
            $ref: "#/components/schemas/PullEntry"
    GeneratedHook:
      type: object
      required: [text, evidence, chapter]
      properties:
        text:
          type: string
        evidence:
          type: string
          description: >
            The pulled quote or scene the hook came from, resolved server-side
            from the id the model cited.
        chapter:
          type: string
    SavedHook:
      type: object
      required:
        [id, text, source, evidence, chapter, sort_index, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
        text:
          type: string
        source:
          type: string
          enum: [author, generated]
          description: >
            Where the hook came from. A DISPLAY LABEL ONLY — nothing filters on
            it, and every kept hook feeds the next generation as an example
            regardless of its value.
        evidence:
          type: string
        chapter:
          type: string
        sort_index:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ContentPull:
      type: object
      required:
        [pull, hooks, hooks_stale, saved_hooks, chapter_count, changed_count]
      properties:
        pull:
          oneOf:
            - $ref: "#/components/schemas/StoredPull"
            - type: "null"
          description: >
            Null when nothing has been read yet, or when what is stored was
            written against a format this build no longer reads.
        hooks:
          oneOf:
            - type: array
              items:
                $ref: "#/components/schemas/GeneratedHook"
            - type: "null"
        hooks_stale:
          type: boolean
          description: >
            True when a hook set exists but one of its inputs has moved since —
            the pull, the brief, the board, the blurb, or the saved hooks.
        saved_hooks:
          type: array
          items:
            $ref: "#/components/schemas/SavedHook"
        chapter_count:
          type: integer
        changed_count:
          type: integer
          description: >
            How many chapters a run would re-read right now. Zero after a run
            with no prose edits since — a rename or a reorder does not count.
    JobKind:
      type: string
      enum:
        [
          reverse_outline,
          ai_outline,
          continuity,
          world_bible,
          book_map,
          entity_dossier,
          chapter_breakdown,
          outline_sketch,
          docx_import,
          content_pull,
        ]
      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` — entity-roster extraction from the
        AI story outline into the world bible (ensures the outline first on a
        cold cache; 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);
        `entity_dossier` — a deep-dive dossier for ONE world-bible entity,
        generated from mention-window excerpts rather than a
        whole-manuscript read (requires `key` in the start request; needs a
        current-format bible; cache hit when neither the entity's forms nor
        any mentioning chapter changed, artifact at GET /dossiers);
        `chapter_breakdown` — reads the author's own plan: the outline text
        (GET /authored-outline) by default, or an authored story map when
        the start request carries `plan: {kind: "map", template_id}`. On an
        EMPTY draft it breaks the plan into a proposed chapter list
        directly. On a draft that already holds chapters it treats the plan
        as an EDIT to the book instead (ensuring the story outline first):
        it writes a **changeset** — how the book changes, remove / add /
        rewrite ops with author-facing reasons, at
        `AuthoredOutline.changeset` — and stops; the author reviews the ops
        and POST /authored-outline/apply-changeset projects the approved
        ones into the proposal deterministically. A start request may carry
        `feedback` (string, ≤4000 chars): the author's reply to the pending
        changeset, which the re-run revises rather than starting over.
        Either way the job writes only the row, stamped with the plan it
        read (`broken_from`); it creates no chapter and touches no
        manuscript. Refuses `authored_outline_empty` / `story_map_empty`
        when the chosen plan has nothing in it; `outline_sketch` — the inverse: writes the
        AUTHOR's outline (~800 words of prose) from the story outline into
        GET /authored-outline's `text`. Refuses with
        `authored_outline_already_written` unless `force`, so a run never
        silently replaces prose the author typed; `docx_import` — the bulk
        chapter import started by POST /projects/{id}/import-docx, the one
        kind that calls no model and is charged for nothing. It cannot be
        started by naming it in POST /jobs (its input is the uploaded file, not
        a JSON body) and such a request is refused with `invalid_kind`; it
        reports progress per chapter and honours DELETE like any other run.
    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 a synthetic
        id for questions that outlived their row (continuity mints
        `cq-<projectId>`) — plain string, not uuid-format. `error` is a wire
        error code (`interrupted` when the running machine died or was
        redeployed; `stalled` when the run stopped making progress and the
        server ended it; `timed_out` when the run exceeded its kind's
        absolute runtime cap; `outline_cancelled` / `outline_failed` when the kind
        needed the AI story outline as a prerequisite and it did not arrive).
        Clients should render `error_detail` rather than recognising codes.
      type: object
      required:
        - id
        - kind
        - project_id
        - label
        - status
        - progress
        - questions
        - error
        - error_detail
        - 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"]
        error_detail:
          type: [string, "null"]
          description: >
            Author-facing explanation of `error`, authored server-side by the
            job kind that failed — the failure-side twin of `summary`. Null
            when the kind has nothing specific to say, in which case the client
            shows its own generic failure line. Clients must not branch on
            `error` codes to produce this text.
        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).
        key:
          type: string
          maxLength: 200
          description: >
            The target world-bible entity key (`BibleEntity.key`). Required
            for `entity_dossier` (400 `invalid_key` when missing); ignored
            by every other kind.
        template:
          type: string
          maxLength: 200
          description: >
            The story template to map onto (`StoryTemplate.id`). Required
            for `book_map` (400 `invalid_template` when missing, and the job
            finishes `invalid_template` when the id resolves to nothing);
            ignored by every other kind.
        plan:
          description: >
            Which plan `chapter_breakdown` reads. Absent means
            `{kind: "prose"}` — the author's outline text, the only plan
            there was before Cards. `{kind: "map", template_id}` reads that
            template's `authored` story map instead; the job finishes
            `story_map_empty` when it has no filled beat. Malformed is 400
            `invalid_plan`. Ignored by every other kind.
          allOf:
            - $ref: "#/components/schemas/PlanRef"
    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.
    ArtDirection:
      description: >
        One sentence describing how a book's generated images should look.
        Capped at 400 characters server-side.
      type: object
      required: [art_direction]
      properties:
        art_direction:
          type: string
          maxLength: 400
    BibleEntityBookRef:
      description: One book's references to an entity.
      type: object
      required:
        [project_id, project_name, chapters, noted_chapters, mention_count, first_appearance]
      properties:
        project_id:
          type: string
          format: uuid
        project_name:
          type: string
        chapters:
          type: array
          items:
            type: string
          description: Chapter filenames mentioning the entity in this book.
        noted_chapters:
          type: array
          items:
            type: string
          description: >
            Chapter filenames whose note names the entity in this book. See
            BibleEntity.noted_chapters.
        mention_count:
          type: integer
        first_appearance:
          type: string
    BibleEntity:
      description: >
        One world-bible entity card. Identity only — the roster is extracted
        from each book's AI story outline, and descriptions live in the
        per-entity dossier (GET /dossiers), generated on demand. `name` is the
        canonical display form (post-override; `extracted_name` records what
        the extraction chose before overrides), `aliases` every other surface
        form (used for identity resolution; they never reach the derived
        spellings, which are canonical names only). `key` is the normalized
        identity the overrides attach to. `notes` is the author's freeform text
        (never model-authored). `origin` is `extracted` when some book's model
        run found it (author notes/aliases may be folded in) or `user` when the
        author added it and no extraction has found it — `user` cards are the
        deletable ones.


        **The roster spans the SERIES.** An entity extracted in book 1 appears
        in book 2's bible too, because what the author knows about a character
        does not restart with each volume. `chapters`, `mention_count` and
        `first_appearance` describe the book you asked about — empty and zero
        when the entity appears only in a sibling — while `books[]` carries
        every book's references. Consumers that read the flat fields mean
        exactly what they meant before the series split.
      type: object
      required:
        - key
        - type
        - name
        - extracted_name
        - aliases
        - first_appearance
        - mention_count
        - hidden
        - chapters
        - noted_chapters
        - books
        - planned_chapters
        - notes
        - physical_description
        - origin
      properties:
        key:
          type: string
        type:
          type: string
          enum: [character, place, term]
        name:
          type: string
        extracted_name:
          type: string
        aliases:
          type: array
          items:
            type: string
        first_appearance:
          type: string
          description: >
            Chapter filename of the earliest mention IN THE BOOK YOU ASKED
            ABOUT. Empty when the entity appears only in a sibling book.
        mention_count:
          type: integer
          description: >
            Chapters mentioning the entity in the book you asked about. Zero
            for a sibling-only entity — see `books` for the series-wide
            picture.
        hidden:
          type: boolean
        chapters:
          type: array
          items:
            type: string
          description: >
            Chapter filenames mentioning the entity in the book you asked
            about, in manuscript order.
        noted_chapters:
          type: array
          items:
            type: string
          description: >
            Chapter filenames whose NOTE names the entity, in the book you
            asked about, in manuscript order. Derived from the author's plan
            board on every bible run, never sent by a client.


            Three sets say three different things and none replaces another:
            `chapters` is what the prose does, `planned_chapters` is what the
            author ticked, and this is what their plan says. A chapter that has
            been committed but not yet written appears only here — it is the
            only record that an entity belongs in a chapter with nothing in it
            yet, and unlike the other two it cannot be lost, because it is
            rebuilt from the notes every run.
        books:
          description: >
            Where this entity stands in each book of the series, in reading
            order. Every book gets an entry, including ones the entity never
            appears in — "not in book 2 yet" is information, and an absent
            entry would read as "unknown".
          type: array
          items:
            $ref: "#/components/schemas/BibleEntityBookRef"
        planned_chapters:
          type: array
          items:
            type: string
          description: >
            Chapters the author plans this entity for IN THE BOOK YOU ASKED
            ABOUT, as **document ids** — not filenames like `chapters`, and
            never derived. Authored in the client and stored verbatim; the
            server never resolves them, so an id whose chapter was deleted
            stays until the author next edits the list. Ids because a planned
            chapter is usually an unwritten stub that gets retitled, and a
            rename doesn't change the id.


            The one authored field that is NOT shared across the series, for
            exactly that reason: a document id means nothing in a sibling book.
        notes:
          type: string
          description: >
            The author's freeform notes on this entity. Shared across the
            series — the same note in every book.
        physical_description:
          type: string
          description: >
            What this entity looks like, in the author's words. Author-owned
            like `notes` — never written server-side, and shared across the
            series — and the text a generated portrait is drawn from
            (POST /api/images). Useful on its own; an entity may carry one with
            no image attached.
        image_asset_id:
          type: string
          description: >
            SERIES asset id of the image the author attached to this entity, if
            any. Rendered through GET /api/series/{id}/assets/{aid} — the
            series id is on the enclosing `Bible`. Series-scoped so the same
            picture renders from every book; the project-scoped asset route
            filters on project_id and would 404 for a sibling's book.
        image_filename:
          type: string
          description: Stored filename of the image asset (carries its extension).
        origin:
          type: string
          enum: [extracted, user]
    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.


        Stored on the SERIES, so a rename or a note made in book 1 is the same
        rename and the same note in book 3. `planned_chapters` is the one
        exception and is stored against the book you sent it from.
      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
              hidden:
                type: boolean
              notes:
                type: string
                maxLength: 8000
                description: The author's freeform notes on this extracted entity.
              physical_description:
                type: string
                maxLength: 8000
                description: >
                  What this extracted entity looks like, in the author's words
                  (attaches by key, survives regeneration like notes).
              planned_chapters:
                type: array
                maxItems: 500
                items:
                  type: string
                  maxLength: 80
                description: >
                  Document ids of the chapters the author plans this extracted
                  entity for (attaches by key, survives regeneration like
                  notes). See BibleEntity.planned_chapters.


                  Sent here with the rest of the author layer, but stored
                  against the BOOK being edited rather than the series: a
                  document id means nothing in a sibling book.
              image_asset_id:
                type: string
                maxLength: 80
                description: >
                  Series asset id of the image attached to this extracted
                  entity (attaches by key, survives regeneration like notes).
                  Uploaded to POST /api/series/{id}/assets.
              image_filename:
                type: string
                maxLength: 255
                description: Stored filename of the image asset.
    UserEntityInput:
      description: >
        An entity the author added by hand, as the client sends it. The
        *derived* chapter-reference fields (first_appearance, mention_count,
        chapters) are server-computed per book on every write and not accepted
        from the client; `planned_chapters` is the author's own and is stored
        against the book being edited.


        Identity, notes, appearance and portrait are stored on the SERIES, so
        they are the same in every book of it.
      type: object
      required: [type, name]
      properties:
        id:
          type: string
          maxLength: 80
          description: >
            Stable identity across renames. Optional on create — the server
            mints one. Send back what you were given, or the entity's per-book
            chapter references and planned chapters are re-keyed and lost.
        type:
          type: string
          enum: [character, place, term]
        name:
          type: string
          maxLength: 200
        aliases:
          type: array
          maxItems: 20
          items:
            type: string
            maxLength: 200
        notes:
          type: string
          maxLength: 8000
        physical_description:
          type: string
          maxLength: 8000
          description: See BibleEntity.physical_description.
        planned_chapters:
          type: array
          maxItems: 500
          items:
            type: string
            maxLength: 80
          description: >
            Document ids of the chapters the author plans this entity for.
            See BibleEntity.planned_chapters.
        hidden:
          type: boolean
        created_at:
          type: string
          maxLength: 40
          description: Client-stamped creation time (ISO 8601); advisory.
        image_asset_id:
          type: string
          maxLength: 80
          description: >
            Series asset id of the image attached to this entity — uploaded to
            POST /api/series/{id}/assets, not the project route.
        image_filename:
          type: string
          maxLength: 255
          description: Stored filename of the image asset.
    UserEntity:
      description: >
        An author-created entity as stored on the SERIES row: identity and
        authored fields only.


        Its chapter references are deliberately absent here — they differ per
        book, are recomputed for each, and reach the client on the matching
        `BibleEntity` card (flat fields for the book you asked about,
        `books[]` for the rest). Read this array when you need the author
        layer to send back; read `entities` when you need to display one.


        `planned_chapters` IS present, and is the one field here that belongs
        to the book you asked about rather than to the series. It is folded
        back in on the way out precisely so this array is the one to send back
        unchanged: a client that echoes what it was given must not erase the
        author's plan as a side effect of editing a note.
      type: object
      required:
        - id
        - type
        - name
        - aliases
        - notes
        - physical_description
        - hidden
        - created_at
      properties:
        id:
          type: string
          description: Stable identity across renames; per-book stats key on it.
        planned_chapters:
          type: array
          items:
            type: string
          description: >
            Document ids planned for this entity IN THE BOOK YOU ASKED ABOUT.
            Absent when there are none.
        type:
          type: string
          enum: [character, place, term]
        name:
          type: string
        aliases:
          type: array
          items:
            type: string
        notes:
          type: string
        physical_description:
          type: string
        hidden:
          type: boolean
        created_at:
          type: string
        image_asset_id:
          type: string
        image_filename:
          type: string
    Bible:
      description: >
        The world bible as seen from one book: every entity in the SERIES, the
        shared author layer, and the series id its entity portraits are stored
        under.
      type: object
      required:
        - format_version
        - series_id
        - generated_at
        - entities
        - user_entities
        - overrides
      properties:
        format_version:
          type: integer
        series_id:
          description: >
            The series this bible belongs to. Entity portraits render through
            GET /api/series/{series_id}/assets/{image_asset_id}.
          type: string
          format: uuid
        generated_at:
          description: >
            The newest extraction time across the series' books; empty when
            none has run.
          type: string
        entities:
          type: array
          items:
            $ref: "#/components/schemas/BibleEntity"
        user_entities:
          type: array
          description: The author's own entities, as stored (with chapter refs).
          items:
            $ref: "#/components/schemas/UserEntity"
        overrides:
          $ref: "#/components/schemas/BibleOverrides"
    ChapterCastEntry:
      type: object
      required: [name, type]
      additionalProperties: true
      description: >
        One entity a chapter is about. Identity only — the world bible owns
        everything else an entity has, and writing a cast never rewrites one.
      properties:
        name:
          type: string
          description: >
            The entity's name in the plan's own spelling. Resolved against the
            bible's existing surface forms, so a spelling the roster already
            knows attaches rather than creating a second card.
        type:
          type: string
          enum: [character, place, term]
    CastResult:
      type: object
      required: [attached, created, carried, detached]
      description: What a cast write did to the world bible.
      properties:
        attached:
          type: integer
          description: Cast entries resolved to an entity and attached to a chapter.
        created:
          type: integer
          description: >
            Entities the bible didn't hold, added to the series' author layer
            as author entities.
        carried:
          type: integer
          description: >
            Plan attachments moved from a superseded chapter onto its redraft.
        detached:
          type: integer
          description: >
            Attachments these chapters had that their new cast doesn't name.
            Always 0 for the outline commit, whose chapters are new.
    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.
    TemplateBeat:
      description: >
        One named beat of a part. `hint` is the craft guidance for what
        belongs here - it reaches the model in the generated prompt, and an
        author filling the beat in by hand.
      type: object
      required: [key, label, hint]
      properties:
        key:
          type: string
        label:
          type: string
        hint:
          type: string
    TemplateNode:
      description: >
        One part of a structure - an act, a phase, a scene, a clue, a
        thread. `title` is the part's name when there is exactly one of it,
        and the kind name when `repeats` is true, in which case the story
        has as many as it has and each instance titles itself.
      type: object
      required: [key, title, repeats, beats, children]
      properties:
        key:
          type: string
        title:
          type: string
        repeats:
          type: boolean
        beats:
          type: array
          items:
            $ref: "#/components/schemas/TemplateBeat"
        children:
          type: array
          items:
            $ref: "#/components/schemas/TemplateNode"
    StoryTemplate:
      description: >
        A story structure. Declares beat names and order only - it knows
        nothing about who fills it in. Clients render `name`, `tradition`,
        and the beat labels; they must never branch on `id`.
      type: object
      required:
        - id
        - name
        - tradition
        - description
        - family
        - origin
        - version
        - header
        - nodes
      properties:
        id:
          type: string
        name:
          type: string
        tradition:
          type: string
          description: The lineage this structure comes from, credited in the picker.
        description:
          type: string
        family:
          type: string
          enum: [universal, genre]
        origin:
          type: string
          enum: [builtin, user]
          description: >
            `builtin` ships in the server's catalog; `user` is one the
            author wrote. The two are merged read-side only - publishing a
            new catalog never writes over a user's own template.
        version:
          type: integer
          description: >
            Bumped when the beats change. A derived map generated against an
            older version regenerates; an authored one is reconciled, never
            discarded.
        header:
          $ref: "#/components/schemas/TemplateNode"
        nodes:
          type: array
          items:
            $ref: "#/components/schemas/TemplateNode"
    StoryTemplatesResponse:
      type: object
      required: [templates]
      properties:
        templates:
          type: array
          items:
            $ref: "#/components/schemas/StoryTemplate"
    MapBeat:
      description: >
        One beat, answered. `label` is copied from the template at fill
        time so the map stays readable after the template changes. A beat
        the story does not contain carries an explicit "Missing - ..."
        value rather than an invented one.


        `id` is present on every beat of an `authored` map (the board
        addresses blocks by it) and absent on `derived` ones — adopt mints
        them on the way across. `key` names the template beat this one
        answers, for looking its `hint` back up; a beat the author wrote
        themselves has none. Nothing branches on either.
      type: object
      required: [label, value, chapters]
      properties:
        id:
          type: string
          description: Stable id. Required on authored maps, never on derived.
        key:
          type: string
          description: The template beat answered, if any. Lineage only.
        label:
          type: string
        value:
          type: string
        chapters:
          type: array
          items:
            type: string
          description: >
            Chapter filenames this beat lands in. Empty is a real answer -
            an act-wide beat has no single chapter.
    MapNode:
      description: >
        One filled-in part of a structure, with its beats and sub-parts.
        `id` follows the same rule as `MapBeat.id`: always on authored maps,
        never on derived. `key` is the template node key, kept for lineage
        and deliberately NOT unique — a repeating part ("Act", "Clue") shares
        it across every instance.
      type: object
      required: [key, title, description, beats, children]
      properties:
        id:
          type: string
          description: Stable id. Required on authored maps, never on derived.
        key:
          type: string
        title:
          type: string
        description:
          type: string
        beats:
          type: array
          items:
            $ref: "#/components/schemas/MapBeat"
        children:
          type: array
          items:
            $ref: "#/components/schemas/MapNode"
    StoryMap:
      type: object
      required:
        - template_id
        - template_version
        - source
        - version
        - generated_at
        - header
        - nodes
        - loose
        - layout
      properties:
        template_id:
          type: string
        template_version:
          type: integer
        source:
          type: string
          enum: [derived, authored]
          description: >
            `derived` was written by the `book_map` job from the
            manuscript; `authored` was filled in by the writer.
        version:
          type: integer
          description: Server-bumped on every write.
        generated_at:
          type: string
          format: date-time
        header:
          $ref: "#/components/schemas/MapNode"
        nodes:
          type: array
          items:
            $ref: "#/components/schemas/MapNode"
        loose:
          type: array
          description: >
            Beats the author has unplugged or written and not placed yet.
            Part of the plan, just not in its order. Always present; empty
            on `derived` rows.
          items:
            $ref: "#/components/schemas/MapBeat"
        layout:
          nullable: true
          description: >
            Where the board draws the chain heads and loose cards. A view,
            not part of the artifact — nothing is stored only here, and null
            means "lay it out again". Always null on `derived` rows.
          allOf:
            - $ref: "#/components/schemas/StoryMapLayout"
    StoryMapLayout:
      description: >
        Where the board draws an authored map. Version 3: a position for every
        beat, in `roots` the beats that head a wired chain (every other beat is
        wired beneath its predecessor in its node's order), and in `frames` the
        box each node — the book, an act, a scene — is drawn as, keyed by node
        id; a beat belongs to the node whose frame it sits in. Versions 1 and 2
        (still read, no longer written) drew nodes as cards in the chain: 1
        positioned heads and loose beats only and a positioned card WAS a head,
        2 positioned every card and listed the heads in `roots`. Throwing it
        away costs one auto-layout, never a word.
      type: object
      required: [format_version, positions]
      properties:
        format_version:
          type: integer
          enum: [1, 2, 3]
        positions:
          type: object
          additionalProperties:
            type: object
            required: [x, y, z]
            properties:
              x: { type: number }
              y: { type: number }
              z: { type: number }
        roots:
          type: array
          items: { type: string }
        frames:
          type: object
          additionalProperties:
            type: object
            required: [x, y, width, height]
            properties:
              x: { type: number }
              y: { type: number }
              width: { type: number }
              height: { type: number }
    AuthoredStoryMapInput:
      description: >
        The map half of an authored row, as the board saves it. Same shape
        as the `header` / `nodes` / `loose` of a StoryMap, with one rule the
        schema cannot express: **every node and every beat carries an
        `id`** (a map without them is 400 `invalid_body`). `chapters` on a
        beat are the author's pins and reach the breakdown as match hints.
      type: object
      required: [header, nodes, loose]
      properties:
        header:
          $ref: "#/components/schemas/MapNode"
        nodes:
          type: array
          items:
            $ref: "#/components/schemas/MapNode"
        loose:
          type: array
          items:
            $ref: "#/components/schemas/MapBeat"
    StoryMapResponse:
      type: object
      required: [story_map]
      properties:
        story_map:
          $ref: "#/components/schemas/StoryMap"
    StoryMapsResponse:
      type: object
      required: [story_maps]
      properties:
        story_maps:
          type: array
          items:
            $ref: "#/components/schemas/StoryMap"
    PlanRef:
      description: >
        Which plan a chapter breakdown reads — the prose outline
        (GET /authored-outline's `text`) or one authored story map. The
        `chapter_breakdown` job's own vocabulary, stamped onto the proposal
        as `AuthoredOutline.broken_from`, so each plan's writer can tell
        whether a pending proposal is its own before voiding it.
      oneOf:
        - type: object
          required: [kind]
          properties:
            kind:
              type: string
              enum: [prose]
        - type: object
          required: [kind, template_id]
          properties:
            kind:
              type: string
              enum: [map]
            template_id:
              type: string
              maxLength: 200
              description: The template whose `authored` map to read.
    DossierSection:
      description: >
        One model-authored dossier section. Titles fit the entity type —
        a character gets Personality / Relationships / Voice, a place gets
        History / Who is found there, a term gets Definition /
        Significance. Bodies are flowing prose. What the entity *looks
        like* is never a section — that is `Dossier.appearance`.
      type: object
      required: [title, body]
      properties:
        title:
          type: string
        body:
          type: string
    DossierTimelineEntry:
      description: >
        One book-order timeline entry, keyed by chapter filename so every
        claim is citable — chapters the excerpts missed are visible as gaps
        rather than silently absent.
      type: object
      required: [chapter, events]
      properties:
        chapter:
          type: string
          description: A chapter filename from `chapters_used`.
        events:
          type: array
          items:
            type: string
    DossierGlanceItem:
      description: >
        One cell of the at-a-glance grid — the fastest reminder of who or
        what an entity is. Labels are model-chosen to fit the entity (a
        character gets Wants / Fears, a place gets Runs on / Forbids)
        rather than drawn from a fixed field list, for the same reason
        `DossierSection` titles are: a fixed list asks every entity the
        same questions and gets padding from the ones it doesn't suit.
      type: object
      required: [label, value]
      properties:
        label:
          type: string
          description: One or two words.
        value:
          type: string
          description: A phrase, at most eight words.
    DossierTie:
      description: >
        One standing relationship to another entity in the bible. The model
        writes a name; the server resolves it against the series roster and
        drops anything that matches nothing, so a tie always points at an
        entity that exists — the same discipline `DossierTimelineEntry`
        chapter citations get.
      type: object
      required: [key, name, relation]
      properties:
        key:
          type: string
          description: >
            Roster key of the tied entity, resolved server-side. Addresses
            the same entity as `BibleEntity.key`.
        name:
          type: string
          description: The tied entity's display name at resolution time.
        relation:
          type: string
          description: >
            What the tied entity is TO this dossier's subject, in 2–5
            words ("Bonded witch") — written from the subject's side,
            because the subject's page is the only place it is shown.
    Dossier:
      description: >
        A deep-dive dossier for one world-bible entity, written from
        mention-window excerpts only. `chapters_used` lists the chapters
        whose excerpts the model saw, in reading order; `truncated` is true
        when the excerpt budget forced dropping some mention windows (very
        frequently-mentioned entities); `stale` is the advisory
        freshness flag computed against current chapter versions.
      type: object
      required:
        - key
        - name
        - type
        - generated_at
        - chapters_used
        - truncated
        - overview
        - appearance
        - glance
        - sections
        - timeline
        - ties
        - stale
      properties:
        key:
          type: string
          description: The world-bible entity key this dossier belongs to.
        name:
          type: string
          description: The entity's display name at generation time.
        type:
          type: string
          enum: [character, place, term]
        generated_at:
          type: string
          format: date-time
        chapters_used:
          type: array
          items:
            type: string
        truncated:
          type: boolean
        overview:
          type: string
        appearance:
          type: string
          description: >
            What the entity looks like, as the excerpts describe it — empty
            when they describe nothing visual. Never repeated as a section.
            This is the draft the client pre-fills the author's own
            `physical_description` with (the text a portrait is drawn from);
            the server never writes that field itself.
        glance:
          type: array
          description: >
            Up to four at-a-glance cells. Empty when the excerpts were too
            thin to fill any — a padded grid is worse than a missing one.
          items:
            $ref: "#/components/schemas/DossierGlanceItem"
        sections:
          type: array
          items:
            $ref: "#/components/schemas/DossierSection"
        timeline:
          type: array
          items:
            $ref: "#/components/schemas/DossierTimelineEntry"
        ties:
          type: array
          description: >
            Resolved links to other bible entities. Empty for an entity the
            excerpts never show alongside a name the roster knows.
          items:
            $ref: "#/components/schemas/DossierTie"
        stale:
          type: boolean
    DossiersResponse:
      type: object
      required: [dossiers]
      properties:
        dossiers:
          type: object
          description: Every cached dossier, keyed by entity key.
          additionalProperties:
            $ref: "#/components/schemas/Dossier"
    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 the `job` object carries what
        the card renders (status, progress, questions_count) — merge it in
        place rather than re-fetching. Fetch
        `GET /api/projects/{id}/jobs/{jobId}` only at the edges: a job this
        client has never seen, a `questions_count` that disagrees with the
        questions in hand, or a terminal status (where `error_detail` and
        `summary` live — they are computed server-side per kind and never
        ride the event). `job` may be absent on a delete (`kind: delete`
        means the row is gone — drop the card) or when the server had to
        fall back to the lean shape; treat its absence as "re-fetch". 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
        job:
          $ref: "#/components/schemas/JobChangePayload"
    JobChangePayload:
      description: >
        The renderable slice of a job row, inlined on `entity: "job"`
        events so progress ticks cost no HTTP round trip. Deliberately a
        subset of JobSnapshot: `questions` (unbounded — verbatim quotes),
        `error_detail` and `summary` (computed in the server process, not
        available to the NOTIFY trigger) require a fetch of the snapshot,
        which the event guidance above bounds to edge transitions.
      type: object
      required:
        [kind, label, status, progress, error, cancel_requested,
         started_at, questions_count]
      properties:
        kind:
          $ref: "#/components/schemas/JobKind"
        label:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        progress:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/JobProgress"
        error:
          type: [string, "null"]
        cancel_requested:
          type: boolean
        started_at:
          type: string
          format: date-time
        questions_count:
          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
