openapi: 3.1.0
info:
  title: VV Studio API
  description: |
    HTTP contract for `Visionvolve/doc-studio` — the authoring kernel for VVDOC
    documents (proposals, decks, marketing). This document is the **partial v0**
    contract per [`docs/ADR/ADR-002-defer-v030-migration.md`](../docs/ADR/ADR-002-defer-v030-migration.md)
    — the full 30+-route surface lands together with the v0.2.x workspaces
    refactor at the next major API change. **What's documented here** is the
    subset actively used by the CLI (`vv-studio`) and the two MCPs
    (`mcp-facilitator.mjs`, `mcp-participant.mjs`).

    Follows `Visionvolve/vv-standards/PATTERNS/api-surface.md`:
    - operationId: `<resource><Verb>` (PascalCase resource + camelCase verb)
    - Error envelope: `{ error: { code, message, details?, requestId? } }`
    - Idempotency-Key on writes (NOT enforced server-side today; reserved)
    - `x-vv-tier` extension on every operation (T0–T5)
    - `x-vv-mcp-expose` extension marks operations exposed via the MCPs
    - Examples on 2xx responses (per §9 — examples become test fixtures)
  version: 0.1.0
  x-coverage: partial
  x-coverage-note: |
    11 of ~30 server routes documented in v0.1.0 (the routes the CLI + MCPs
    actively use). Remaining routes (comments, asset upload, checkpoints,
    library, AI generation, PDF/PNG export, internal collab subroutes) land
    with the v0.2.x migration per ADR-002.
  contact:
    name: doc-studio (Visionvolve/doc-studio)
    url: https://github.com/Visionvolve/doc-studio

servers:
  - url: http://localhost:5174
    description: Local dev (default port; loopback)
  - url: https://studio-staging.visionvolve.com
    description: Staging (planned — not deployed today)

# ── Tags ──────────────────────────────────────────────────────────────────────
tags:
  - name: meta
    description: Service introspection (health, info)
  - name: decks
    description: Document-level operations (read, render, validate, sign, verify)
  - name: state
    description: Document state (model + locks + participants)
  - name: ops
    description: Realtime collaboration operations (add/update/delete elements, slides, masters)
  - name: integrity
    description: VVDOC signing + verification (ed25519 over canonicalized hash)

# ── Security ──────────────────────────────────────────────────────────────────
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        IAM-issued JWT per `vv-standards/SCHEMAS/iam-claims/1.0/`. Verified
        per `vv-standards/PATTERNS/iam-consumer.md`. Local dev with
        `AUTH_REQUIRED=0` accepts anonymous requests.

  parameters:
    PathParam:
      name: path
      in: query
      required: true
      description: |
        Relative path to the .qmd file (Studio routes by .qmd; the VVDOC
        model lives at `<dir>/studio.model.json` as a sidecar).
      schema:
        type: string
        example: "deck-demo/proposal.qmd"
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        UUIDv4 for write-op deduplication. Reserved for v0.2.x; not enforced
        server-side today. CLIs and MCPs SHOULD send it on every write.
      schema:
        type: string
        format: uuid

  schemas:
    # The standard error envelope — uniform across the suite.
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable code (e.g. NOT_FOUND, UNAUTHORIZED, VALIDATION_FAILED).
              example: VALIDATION_FAILED
            message:
              type: string
              description: Human-readable; safe to display.
              example: "frame 0 element 'el-3': html contains threat(s): script-tag"
            details:
              type: object
              additionalProperties: true
              description: Field-level errors, threat lists, etc.
            requestId:
              type: string
              description: Trace id; mirrors X-Request-Id response header.

    HealthResponse:
      type: object
      required: [status, service, version]
      properties:
        status: { type: string, enum: [ok, degraded, down] }
        service: { type: string, example: "vv-studio" }
        version: { type: string, example: "0.1.0" }
        git_sha: { type: string, example: "3385e59" }
        mcp_facilitator: { type: string, description: "Path to facilitator MCP entry." }
        mcp_participant: { type: string, description: "Path to participant MCP entry." }
        cli: { type: string, description: "Path to CLI entry." }
        started_at: { type: string, format: date-time }

    InfoResponse:
      type: object
      required: [pages]
      properties:
        pages: { type: integer, description: "Number of frames (pages/slides) in the deck.", example: 3 }
        kind: { type: string, enum: [proposal, deck, onepager, marketing] }
        status: { type: string, enum: [draft, in-review, approved, sent] }

    # VVDOC reference (the canonical schema is in vv-standards SCHEMAS/vvdoc/1.0/).
    VVDoc:
      type: object
      description: "See `Visionvolve/vv-standards/SCHEMAS/vvdoc/1.0/schema.json` for the full schema. This endpoint returns a doc matching that contract."
      externalDocs:
        description: VVDOC 1.0 spec
        url: https://github.com/Visionvolve/vv-standards/blob/main/SCHEMAS/vvdoc/1.0/SPEC.md

    StateResponse:
      type: object
      required: [ok, model]
      properties:
        ok: { type: boolean }
        model: { $ref: "#/components/schemas/VVDoc" }
        locks: { type: array, items: { $ref: "#/components/schemas/Lock" } }
        participants: { type: array, items: { $ref: "#/components/schemas/Participant" } }

    Lock:
      type: object
      properties:
        elementId: { type: string }
        by: { type: string, description: "Participant id holding the lock." }
        expiresAt: { type: string, format: date-time }

    Participant:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        role: { type: string, enum: [admin, editor, agent, viewer], description: "Server-derived from IAM claims, NOT client-declared." }
        joinedAt: { type: string, format: date-time }

    Op:
      type: object
      description: "Realtime op. Discriminator: `type`."
      required: [type]
      properties:
        type:
          type: string
          enum: [add, update, delete, page, master, reorder]
        frame: { type: integer, description: "Slide/page index (0-based). Required for add/update/delete/reorder." }
        element: { type: object, description: "Element payload for `add`." }
        elementId: { type: string, description: "Element id for `update`/`delete`." }
        props: { type: object, description: "Patch for `update`." }
        apply:
          type: boolean
          description: "Commit directly. If false (default for agent role), the op is recorded as `proposed: true`."
        force:
          type: boolean
          description: "Bypass propose-by-default + cross-author edit blocks (admin-only effectively)."

    OpResponse:
      type: object
      required: [ok, applied]
      properties:
        ok: { type: boolean }
        applied: { type: boolean, description: "False when op was recorded as a proposal." }
        elementId: { type: string }
        seq: { type: integer, description: "Audit log sequence number." }
        __forbidden: { type: object, description: "Present when governance blocked the op (e.g. agent editing another agent's element)." }
        __conflict: { type: object, description: "Present on lock conflicts." }

    ValidateResponse:
      type: object
      required: [ok, errors, warnings]
      properties:
        ok: { type: boolean }
        errors:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              msg: { type: string }
              where: { type: string }
        warnings:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              msg: { type: string }
              where: { type: string }

    SignResponse:
      type: object
      required: [hash]
      properties:
        hash: { type: string, description: "SHA-256 of canonicalized doc (sans integrity.signatures)." }
        signature:
          type: object
          properties:
            kid: { type: string }
            alg: { type: string, enum: [ed25519] }
            sig: { type: string }
            at: { type: string, format: date-time }
            by: { type: string }

    VerifyResponse:
      type: object
      required: [ok, verified]
      properties:
        ok: { type: boolean }
        verified: { type: boolean }
        signatures:
          type: array
          items:
            type: object
            properties:
              kid: { type: string }
              alg: { type: string }
              verified: { type: boolean }
              by: { type: string }
              at: { type: string, format: date-time }

  responses:
    Error4xx:
      description: Client error (validation, not-found, forbidden, conflict).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Error5xx:
      description: Server error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

# ── Default security: bearerAuth except where overridden ──────────────────────
#
# Every operation inherits this global Bearer requirement unless it sets
# `security: []` (currently only `/health`). When `AUTH_REQUIRED=1` is set
# server-side (staging, prod), unauthenticated requests get the standard
# error envelope:
#   401 + { "error": { "code": "AUTH_REQUIRED",  "message": "..." } }  (missing token)
#   401 + { "error": { "code": "INVALID_TOKEN",  "message": "..." } }  (bad token)
#   403 + { "error": { "code": "NO_ACCESS",      "message": "..." } }  (no vv-studio role
#                                                                      OR project ACL denies)
# Routes under /api/collab/* have their own per-op enforcement via
# collab.enforceWrite — see tools/editor/collab.js. They're skipped by the
# render-surface middleware (tools/editor/server.js::requireIamWhenAuthOn).
#
# Per-project ACL: when a project's folder contains a `.acl.json`, every
# path-bearing endpoint additionally checks the caller's identity against
# the ACL (`tools/editor/access-control.js`). 403 `NO_ACCESS` then indicates
# either: token has no vv-studio role, OR the project ACL denies the
# requested operation. See `docs/SECURITY.md` for the full model.
security:
  - bearerAuth: []

# ── Paths ─────────────────────────────────────────────────────────────────────
paths:

  # ── Meta ────────────────────────────────────────────────────────────────────
  /health:
    get:
      tags: [meta]
      operationId: serviceHealth
      summary: Service health probe
      description: |
        Unauthenticated liveness probe. Returns `{status, service, version, git_sha, mcp_*, cli, started_at}`.
        Used by docker healthchecks, Caddy upstream probes, and CI smoke tests.
      security: []  # unauthenticated
      x-vv-tier: T1
      x-vv-mcp-expose: false
      responses:
        "200":
          description: Service is healthy.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }
              examples:
                healthy:
                  summary: A healthy doc-studio instance
                  value:
                    status: ok
                    service: vv-studio
                    version: "0.1.0"
                    git_sha: "3385e59"
                    mcp_facilitator: "tools/editor/mcp-facilitator.mjs"
                    mcp_participant: "tools/editor/mcp-participant.mjs"
                    cli: "tools/editor/cli.mjs"
                    started_at: "2026-05-29T16:00:00.000Z"

  /api/studio/render:
    post:
      tags: [decks]
      operationId: decksRenderWithContext
      summary: Render a VVDOC (with optional data context + computed sections) — CA-DS-05
      description: |
        **T1 surface.** Accepts an inline VVDOC OR a `vvdoc_ref` (stored doc path),
        plus an optional `data_context` (resolved against `{{path}}` placeholders
        in `element.html`, `element.data.*`, `frame.notes`, `meta.title` — see
        VVDOC 1.1 spec) and `computed_sections` (resolved against
        `type:"computed-section"` elements), and renders to the requested
        format.

        Implements VVDOC 1.1 semantics — full reference at
        `vv-standards/SCHEMAS/vvdoc/1.1/SPEC.md`.

        Substitution defaults to lenient mode (missing path → empty + warning
        in `X-Substitution-Missing` header). Strict mode rejects with
        `code: SUBSTITUTION_FAILED`. Computed-section recursion is bounded by
        `options.computed_sections_recursion_limit` (default 3); breach →
        `code: RECURSION_LIMIT`.

        After substitution + injection, the resulting document is re-screened
        for HTML threats (no `<script>`, no `on*=`, no `<iframe>/<object>/<embed>`)
        before rendering. Threats → `code: HTML_THREAT`.

        v0.1: `html` + `revealjs` formats implemented. `pdf` + `png` return
        `code: FORMAT_NOT_IMPLEMENTED`; use the legacy path-based endpoints
        for those today.
      x-vv-tier: T1
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format]
              oneOf:
                - required: [vvdoc]
                - required: [vvdoc_ref]
              properties:
                format: { type: string, enum: [html, revealjs, pdf, png] }
                vvdoc: { $ref: "#/components/schemas/VVDoc", description: "Inline VVDOC body (v1.0 or v1.1)." }
                vvdoc_ref:
                  oneOf:
                    - type: string
                      description: "Path to a stored .qmd (Studio reads the `<dir>/studio.model.json` sidecar)."
                    - type: object
                      required: [id]
                      properties:
                        id: { type: string, description: "Content-Hub doc id (future)." }
                        version: { type: integer }
                data_context:
                  type: object
                  description: "Opaque JSON; doc-studio resolves `{{path}}` placeholders against it. See VVDOC 1.1 SPEC §3.1."
                  additionalProperties: true
                computed_sections:
                  type: object
                  description: "Map of `<id>` → sub-VVDOC fragment (object) OR sanitized HTML string. Resolves `type:\"computed-section\"` elements at render time. See VVDOC 1.1 SPEC §3.2."
                  additionalProperties:
                    oneOf:
                      - { $ref: "#/components/schemas/VVDoc" }
                      - { type: string, description: "Sanitized HTML; re-screened for threats." }
                options:
                  type: object
                  properties:
                    strict_substitution:
                      type: boolean
                      default: false
                      description: "If true, missing `{{path}}` rejects with SUBSTITUTION_FAILED. Default lenient (missing → empty + X-Substitution-Missing header)."
                    computed_sections_recursion_limit:
                      type: integer
                      default: 3
                      description: "Max nested computed-section depth. Breach → RECURSION_LIMIT."
                    page:
                      type: integer
                      description: "Reserved for png/pdf single-page rendering."
            examples:
              inline-with-substitution:
                summary: Inline VVDOC + data_context (substitution-only)
                value:
                  format: "revealjs"
                  vvdoc:
                    vvdoc: "1.1"
                    id: "deck-1"
                    kind: "deck"
                    meta: { title: "Hello {{participant.firstName}}", status: "draft" }
                    frames:
                      - w: 1920
                        h: 1080
                        elements:
                          - { id: "e1", x: 80, y: 200, w: 600, h: 120, z: 1, html: "<h1>{{participant.firstName}}</h1>" }
                  data_context:
                    participant: { firstName: "Eva" }
              report-with-computed-sections:
                summary: Report template + sub-VVDOC fragments for slots
                value:
                  format: "html"
                  vvdoc_ref: "retrofit/drmax-m1/proposal.qmd"
                  data_context: { participant: { firstName: "Eva" } }
                  computed_sections:
                    summary: "<p>Eva completed all five modules...</p>"
                    topBizcase:
                      vvdoc: "1.1"
                      id: "frag-bizcase"
                      kind: "proposal"
                      meta: { status: "draft" }
                      frames:
                        - w: 670
                          h: 380
                          elements:
                            - { id: "h", x: 0, y: 0, w: 670, h: 60, z: 1, html: "<h3>Automate Publication Processing</h3>" }
      responses:
        "200":
          description: Rendered output. Content-Type matches the requested format.
          headers:
            X-Request-Id:
              schema: { type: string }
              description: Trace id; mirrors any Idempotency-Key or generated server-side.
            X-Substitution-Missing:
              schema: { type: string }
              description: Comma-separated list of (up to 10) unresolved `{{path}}` placeholders in lenient mode. Absent if strict or all resolved.
          content:
            text/html: { schema: { type: string } }
            application/pdf: { schema: { type: string, format: binary } }
            image/png: { schema: { type: string, format: binary } }
        "400":
          description: |
            Validation, substitution, or rendering error. Standard error envelope.
            Codes: BAD_FORMAT · MISSING_SOURCE · BAD_REF · REF_NOT_FOUND ·
            VALIDATION_FAILED · SUBSTITUTION_FAILED · COMPUTED_SECTION_FAILED ·
            RECURSION_LIMIT · HTML_THREAT · FORMAT_NOT_IMPLEMENTED.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Error4xx" }
        "500": { $ref: "#/components/responses/Error5xx" }

  /api/studio/render-with-patch:
    post:
      tags: [decks]
      operationId: decksRenderWithPatch
      summary: Render a stored VVDOC with a per-cohort patch applied.
      description: |
        Phase A workshop-integration endpoint. Loads a base VVDOC (by
        `docId` or `vvdoc_ref`, or accepts it inline as `vvdoc`), applies
        a JSONB patch matching workshop's `cohorts.slide_overrides` shape,
        then runs the same data-context + computed-section pipeline as
        `POST /api/studio/render`.

        **Patch shape** (per workshop's cross-repo ask):

        ```jsonc
        {
          "frame-3": {                    // frame key (positional or frame.id)
            "elements": [                 // partial element overrides (matched by id)
              { "id": "title-1", "html": "<h1>Custom title</h1>" },
              { "id": "bullets-1", "x": 200, "y": 400 }
            ],
            "hidden": true,               // exclude this frame from output
            "notes": "Facilitator note"
          }
        }
        ```

        Hidden frames are filtered from the rendered output. Patches that
        reference unknown frame keys or element ids produce warnings
        (surfaced via `X-Patch-Warnings`) but never fail the render.

        See: `docs/ERROR-ENVELOPE.md` for the error-code catalog.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format]
              properties:
                format:
                  type: string
                  enum: [html, revealjs, reveal-html, pdf, png]
                  description: |
                    Output format. `reveal-html` is accepted as an alias
                    for `revealjs` (matches the cross-repo ask).
                vvdoc:
                  type: object
                  description: Inline base VVDOC. Mutually exclusive with `docId` / `vvdoc_ref`.
                docId:
                  type: string
                  description: Alias for `vvdoc_ref`. Points at a stored deck.
                vvdoc_ref:
                  type: string
                  description: Path to a stored deck (e.g. `slides/drmax-m1.qmd`).
                patch:
                  type: object
                  description: |
                    JSONB patch keyed by frame identifier. See description.
                data_context:
                  type: object
                  description: Mustache-style `{{path}}` substitution context.
                computed_sections:
                  type: object
                  description: Map of section-id → html | sub-VVDOC.
                options:
                  type: object
                  properties:
                    strict_substitution: { type: boolean, default: false }
                    computed_sections_recursion_limit: { type: integer, default: 3 }
      responses:
        "200":
          description: Rendered output.
          headers:
            X-Request-Id: { schema: { type: string } }
            X-Substitution-Missing: { schema: { type: string }, description: "CSV of unresolved {{path}}s (lenient mode)." }
            X-Patch-Warnings: { schema: { type: string }, description: "Count of patch warnings (unknown frame/element ids)." }
          content:
            text/html:
              schema: { type: string }
        "400":
          description: Validation / patch / substitution failure.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Error4xx" }
        "500": { $ref: "#/components/responses/Error5xx" }

  /api/studio/info:
    get:
      tags: [meta]
      operationId: decksInfo
      summary: Document info (page count + kind + status)
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: Document info.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfoResponse" }
              examples:
                deck-3-slides:
                  summary: 3-slide deck-demo
                  value: { pages: 3, kind: deck, status: draft }
        "400": { $ref: "#/components/responses/Error4xx" }
        "404": { $ref: "#/components/responses/Error4xx" }

  # ── Decks (read + render + validate + sign + verify) ───────────────────────
  /api/studio/export-revealjs:
    get:
      tags: [decks]
      operationId: decksRenderRevealjs
      summary: Render a deck as a self-contained reveal.js HTML document
      description: |
        Studio model → reveal.js HTML. Each frame becomes a `<section>`;
        `kind:"deck"` fields (transition / background / notes / fragments)
        are honored. CDN-loaded reveal.js + brand theme. The returned HTML
        is fully standalone (open in a browser, no server required).
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: A self-contained reveal.js HTML document.
          content:
            text/html:
              schema: { type: string, description: "Reveal.js deck HTML." }
              examples:
                deck-demo-rendered:
                  summary: 3-slide deck rendered (header only)
                  value: |
                    <!DOCTYPE html><html><head>...<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">...</head><body><div class="reveal"><div class="slides"><section data-transition="fade" data-background-color="#4A1D5E">...</section>...</div></div></body></html>

  /api/studio/export-html:
    get:
      tags: [decks]
      operationId: decksRenderHtml
      summary: Render a document as self-contained HTML (non-reveal)
      description: |
        Used for proposals / onepagers / marketing — single-page HTML with
        the brand theme inlined. The reveal.js export is at `decksRenderRevealjs`.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: A self-contained HTML document.
          content:
            text/html:
              schema: { type: string }

  /api/collab/validate:
    get:
      tags: [decks]
      operationId: decksValidate
      summary: Validate a VVDOC against vvdoc/1.0
      description: |
        Validates against the canonical schema at
        `vv-standards/SCHEMAS/vvdoc/1.0/schema.json` PLUS the semantic
        constraints in the spec (element-id uniqueness, HTML threat scan,
        deck-only field warnings). Errors block; warnings are advisory.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters:
        - { $ref: "#/components/parameters/PathParam" }
        - name: strict
          in: query
          required: false
          description: "Enable brand-strict colour warnings."
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Validation result (with errors/warnings — `ok` is false if errors present).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidateResponse" }
              examples:
                valid:
                  summary: A clean deck
                  value: { ok: true, errors: [], warnings: [] }
                with-warnings:
                  summary: Validates but with brand warnings
                  value:
                    ok: true
                    errors: []
                    warnings:
                      - { code: "off-brand-color", msg: "non-brand color in html: #FF00FF", where: "/frames/1/elements/2" }

  /api/collab/sign:
    post:
      tags: [integrity]
      operationId: decksSign
      summary: Approve + sign a VVDOC (ed25519)
      description: |
        Requires `meta.status` transitionable to `approved`. Signs the
        canonicalized doc (excluding `integrity.signatures`) with ed25519;
        appends to `integrity.signatures[]`. Implements
        `vv-standards/SCHEMAS/vvdoc/1.0/SPEC.md` §4.9.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: { type: string, description: "The .qmd path." }
                by: { type: string, description: "Signer identifier (email/email-like)." }
      responses:
        "200":
          description: Document signed; signature appended.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignResponse" }
              examples:
                signed:
                  summary: A freshly approved deck
                  value:
                    hash: "abc123..."
                    signature:
                      kid: studio-v1
                      alg: ed25519
                      sig: "BASE64..."
                      at: "2026-05-29T16:00:00.000Z"
                      by: "anton@visionvolve.ai"

  /api/collab/verify:
    get:
      tags: [integrity]
      operationId: decksVerify
      summary: Verify a VVDOC's signature(s)
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: Verification result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyResponse" }
              examples:
                verified:
                  summary: All signatures valid
                  value:
                    ok: true
                    verified: true
                    signatures:
                      - { kid: studio-v1, alg: ed25519, verified: true, by: "anton@visionvolve.ai", at: "2026-05-29T16:00:00.000Z" }

  # ── State + ops ────────────────────────────────────────────────────────────
  /api/collab/state:
    get:
      tags: [state]
      operationId: stateGet
      summary: Get the live VVDOC state + locks + participants
      description: |
        The full authoritative model + active locks + connected participants.
        This is what the MCP `studio_view_deck` / `studio_decks_get` tools
        read. Read does NOT join a collab session — use `/api/collab/join` first
        if you intend to submit ops.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: Document state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateResponse" }
              examples:
                clean-deck:
                  summary: A 3-slide deck with no active editors
                  value:
                    ok: true
                    model: { vvdoc: "1.0", kind: deck, meta: { title: "Demo", status: draft }, frames: [{ w: 1920, h: 1080, elements: [] }] }
                    locks: []
                    participants: []

  /api/collab/join:
    post:
      tags: [ops]
      operationId: sessionJoin
      summary: Join a collaboration session
      description: |
        Returns a session id used by subsequent op submissions. The server
        derives role/identity from the IAM token; the `role` field in the
        request body is advisory only.
      x-vv-tier: T2
      x-vv-mcp-expose: false  # called internally by clients; not surfaced to agents
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: { type: string }
                name: { type: string, description: "Display name for cursors/audit." }
                role:
                  type: string
                  enum: [admin, editor, agent, viewer]
                  description: "Advisory. Server overrides from IAM claims."
      responses:
        "200":
          description: Session joined.
          content:
            application/json:
              schema:
                type: object
                required: [id, role]
                properties:
                  id: { type: string, description: "Session id (used as `id` in `/api/collab/op`)." }
                  role: { type: string, description: "Server-derived." }
                  name: { type: string }

  /api/collab/op:
    post:
      tags: [ops]
      operationId: opsSubmit
      summary: Submit a realtime op
      description: |
        The core write path. All element/slide/master mutations go through
        here. Op-gate enforces: HTML safety (`<script>`/`on*=`/`<iframe>` reject),
        VVDOC schema, propose-by-default for agents, cross-author edit blocks.
        Successful ops broadcast over SSE to all subscribers (`/api/collab/stream`).
      x-vv-tier: T3
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, id, op]
              properties:
                path: { type: string }
                id: { type: string, description: "Session id from `sessionJoin`." }
                op: { $ref: "#/components/schemas/Op" }
            examples:
              add-element:
                summary: Add an HTML element to slide 0
                value:
                  path: "deck-demo/proposal.qmd"
                  id: "session-abc-123"
                  op:
                    type: add
                    frame: 0
                    element:
                      id: "el-new-001"
                      x: 100
                      y: 100
                      w: 600
                      h: 80
                      z: 1
                      html: "<h1 style=\"color:#fff;font:700 56px Lexend Deca,sans-serif\">Hello</h1>"
                    apply: true
      responses:
        "200":
          description: Op accepted (and either applied or recorded as proposed).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OpResponse" }
              examples:
                applied:
                  summary: Op committed
                  value: { ok: true, applied: true, elementId: "el-new-001", seq: 42 }
                proposed:
                  summary: Agent op proposed (apply omitted)
                  value: { ok: true, applied: false, elementId: "el-new-001", seq: 43 }
        "400": { $ref: "#/components/responses/Error4xx" }
        "403":
          description: Governance refusal (e.g. agent editing another agent's element without --force).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                cross-author-blocked:
                  summary: Agent A tried to update Agent B's element
                  value: { error: { code: "CROSS_AUTHOR_EDIT", message: "Cannot modify elements you didn't author. Use force=true to override." } }
        "409":
          description: Lock conflict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ── Meta updates ───────────────────────────────────────────────────────────
  /api/collab/meta:
    post:
      tags: [decks]
      operationId: metaSet
      summary: Update document meta (title, status, client, owner)
      description: |
        Status transitions are state-machine-gated: draft → in-review → approved → sent.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, set]
              properties:
                path: { type: string }
                set:
                  type: object
                  description: "Fields to update on meta."
                  properties:
                    title: { type: string }
                    status: { type: string, enum: [draft, in-review, approved, sent] }
                    client: { type: string }
                    owner: { type: string }
      responses:
        "200":
          description: Meta updated; new meta returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  meta: { type: object }
              examples:
                approved:
                  summary: Status moved to approved (auto-triggers signing prompt downstream)
                  value: { ok: true, meta: { title: "Deck Demo", status: approved, modified: "2026-05-29T16:00:00.000Z" } }

  # ───────────────────────────────────────────────────────────────────────────
  # Filesystem + library + checkpoint + comment + export-binary endpoints
  # (documented in bulk 2026-06-02 to close the openapi coverage gap)
  # ───────────────────────────────────────────────────────────────────────────

  /api/file:
    get:
      tags: [files]
      operationId: fileGet
      summary: Read a .qmd file's contents.
      x-vv-tier: T2
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: File contents.
          content:
            application/json:
              schema:
                type: object
                required: [path, content]
                properties:
                  path: { type: string }
                  content: { type: string }
        "400": { $ref: "#/components/responses/Error4xx" }
    put:
      tags: [files]
      operationId: filePut
      summary: Write a .qmd file's contents.
      x-vv-tier: T2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, content]
              properties:
                path: { type: string }
                content: { type: string }
      responses:
        "200":
          description: Written.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/list:
    get:
      tags: [files]
      operationId: filesList
      summary: List all .qmd files under the repo root.
      x-vv-tier: T2
      responses:
        "200":
          description: List of relative paths.
          content:
            application/json:
              schema:
                type: object
                properties:
                  files: { type: array, items: { type: string } }

  /api/preview:
    get:
      tags: [files]
      operationId: previewStart
      summary: Ensure a Quarto preview is running for the given .qmd; returns its URL.
      x-vv-tier: T2
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: Preview URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
                  reused: { type: boolean }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/doc:
    put:
      tags: [docs]
      operationId: docPut
      summary: Save edited HTML body of an ingested .qmd doc (with SVG byte-restore guarantee).
      x-vv-tier: T2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, html]
              properties:
                path: { type: string }
                html: { type: string }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  svgs: { type: integer, description: "SVG count restored." }
        "400":
          description: SVG count mismatch (save aborted to protect visuals) or other validation failure.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/studio/model:
    post:
      tags: [decks]
      operationId: decksModelSave
      summary: Save the full VVDOC model to disk.
      x-vv-tier: T2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, model]
              properties:
                path: { type: string }
                model: { type: object, description: "A VVDOC. Validated server-side." }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  frames: { type: integer }
                  elements: { type: integer }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/studio/page:
    post:
      tags: [decks]
      operationId: decksPageSave
      summary: Save a single frame of an existing VVDOC model.
      x-vv-tier: T2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, frameIndex, frame]
              properties:
                path: { type: string }
                frameIndex: { type: integer, minimum: 0 }
                frame: { type: object, description: "VVDOC frame." }
                globalDefs:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  frameIndex: { type: integer }
                  elements: { type: integer }
                  pages: { type: integer }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/studio/asset:
    post:
      tags: [decks]
      operationId: decksAssetUpload
      summary: Upload a deck asset (base64-encoded image) into the per-doc assets directory.
      x-vv-tier: T2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, filename, dataB64]
              properties:
                path: { type: string }
                filename: { type: string }
                dataB64: { type: string, description: "Base64-encoded asset bytes." }
      responses:
        "200":
          description: Stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  url: { type: string }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/library:
    get:
      tags: [library]
      operationId: libraryList
      summary: List template library entries (seed + user-saved).
      x-vv-tier: T2
      x-vv-mcp-expose: true
      responses:
        "200":
          description: Library entries.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    name: { type: string }
                    category: { type: string }
                    w: { type: number }
                    h: { type: number }
                    html: { type: string }
                    createdAt: { type: string, format: date-time }
    post:
      tags: [library]
      operationId: librarySave
      summary: Save a new template entry to the user library.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, html]
              properties:
                name: { type: string }
                category: { type: string, default: custom }
                html: { type: string }
                w: { type: number, default: 300 }
                h: { type: number, default: 140 }
      responses:
        "200":
          description: Stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  item: { type: object }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/studio/checkpoint:
    post:
      tags: [history]
      operationId: checkpointCreate
      summary: Create a named checkpoint of the current saved model.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: { type: string }
                label: { type: string }
      responses:
        "200":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string }
                  count: { type: integer }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/studio/checkpoints:
    get:
      tags: [history]
      operationId: checkpointsList
      summary: List checkpoints for the doc (most-recent first).
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: Checkpoints (empty array if none).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    label: { type: string }
                    at: { type: string, format: date-time }
                    summary: { type: string }

  /api/studio/restore:
    post:
      tags: [history]
      operationId: checkpointRestore
      summary: Restore a checkpoint as the current saved model.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, id]
              properties:
                path: { type: string }
                id: { type: string }
      responses:
        "200":
          description: Restored.
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/studio/pdf:
    get:
      tags: [export]
      operationId: pdfExport
      summary: Render the full model to PDF (headless Chrome, all pages).
      x-vv-tier: T2
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: PDF bytes.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "500":
          description: Render failure.
          content:
            text/plain:
              schema: { type: string }

  /api/studio/png:
    get:
      tags: [export]
      operationId: pngExport
      summary: Render a single page to PNG (headless Chrome).
      x-vv-tier: T2
      parameters:
        - { $ref: "#/components/parameters/PathParam" }
        - in: query
          name: page
          required: false
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: PNG bytes.
          content:
            image/png:
              schema: { type: string, format: binary }
        "500":
          description: Render failure.
          content:
            text/plain:
              schema: { type: string }

  /api/studio/comments:
    get:
      tags: [review]
      operationId: commentsList
      summary: List review comments (pins) for a doc.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      parameters: [{ $ref: "#/components/parameters/PathParam" }]
      responses:
        "200":
          description: Comments (empty array if none).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    page: { type: integer }
                    x: { type: integer }
                    y: { type: integer }
                    text: { type: string }
                    resolved: { type: boolean }
                    at: { type: string, format: date-time }
                    replies: { type: array, items: { type: object } }

  /api/studio/comment:
    post:
      tags: [review]
      operationId: commentAdd
      summary: Add a review comment pin.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, text]
              properties:
                path: { type: string }
                page: { type: integer, default: 0 }
                x: { type: integer }
                y: { type: integer }
                text: { type: string, maxLength: 1000 }
      responses:
        "200":
          description: Added.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  comment: { type: object }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/studio/comment/update:
    post:
      tags: [review]
      operationId: commentUpdate
      summary: Update or delete a review comment.
      x-vv-tier: T2
      x-vv-mcp-expose: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, id]
              properties:
                path: { type: string }
                id: { type: string }
                resolved: { type: boolean }
                text: { type: string }
                reply: { type: string }
                del: { type: boolean }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  comment:
                    nullable: true
                    type: object

  /api/studio/ai:
    post:
      tags: [ai]
      operationId: aiAuthor
      summary: Generate HTML or revise text via Claude (claude CLI subprocess).
      x-vv-tier: T2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instruction]
              properties:
                instruction: { type: string }
                text: { type: string }
                mode:
                  type: string
                  enum: [element, text]
      responses:
        "200":
          description: Generated / revised text.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  text: { type: string }
        "500":
          description: Claude CLI failure.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/studio/masters:
    post:
      tags: [decks]
      operationId: mastersSave
      summary: Save doc-level masters (header/footer elements repeated on every page).
      x-vv-tier: T2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, masters]
              properties:
                path: { type: string }
                masters:
                  type: array
                  items: { type: object }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  masters: { type: integer }
        "400": { $ref: "#/components/responses/Error4xx" }

  /api/events:
    get:
      tags: [collab]
      operationId: eventsStream
      summary: Server-Sent Events stream for repo-level file events (file watcher).
      x-vv-tier: T5
      responses:
        "200":
          description: Stream of SSE events.
          content:
            text/event-stream:
              schema: { type: string }

# ── External references ──────────────────────────────────────────────────────
externalDocs:
  description: |
    Companion spec — VVDOC 1.0 (the document format every operation produces/consumes).
  url: https://github.com/Visionvolve/vv-standards/blob/main/SCHEMAS/vvdoc/1.0/SPEC.md
