openapi: 3.1.0
info:
  title: SolidActions API
  version: 1.0.0
  description: >
    REST API for SolidActions. v1 surface. Currently documents the Docs surface
    (docs, folders, doc-types, views, trash). Other /api/v1 surfaces are added here as they are typed.

    **Required header:** every request must include `Accept: application/json`. Without it, error
    responses are rendered as HTML (Laravel's default behaviour). The `Content-Type: application/json`
    header is also required on request bodies.
servers:
  - url: https://app.solidactions.com/api/v1
    description: Production
  - url: https://e2e.formup.cc/api/v1
    description: Staging
security:
  - bearerAuth: []
tags:
  - name: Docs
    description: "Create, read, update, delete docs"
  - name: Doc Body & Properties
    description: "Body edits, property and type mutations"
  - name: Versions
    description: "Doc version history, diff, rollback"
  - name: Folders
    description: Folder CRUD and moves
  - name: Doc Types
    description: Doc type definitions and their properties
  - name: Views
    description: Saved queries over docs
  - name: Trash
    description: Soft-deleted docs and folders
  - name: Graph
    description: "Backlinks, links, unresolved/stale references"
  - name: Bulk
    description: Bulk create/read of docs
  - name: Media
    description: Binary media docs — multipart upload and signed-URL reads
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Sanctum personal access token. Send as `Authorization: Bearer <token>`."
  parameters:
    WorkspaceId:
      name: X-Workspace-Id
      in: header
      required: true
      description: Workspace UUID. Required on every request.
      schema:
        type: string
        format: uuid
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: "Optional. On non-safe methods, replays return the original response. 1..255 ASCII chars."
      schema:
        type: string
        maxLength: 255
    IfMatch:
      name: If-Match
      in: header
      required: false
      description: "Optional optimistic-concurrency guard where supported (WebDAV-style)."
      schema:
        type: string
    RequestId:
      name: X-Request-Id
      in: header
      required: false
      description: "Optional client-supplied request id (ASCII, <=64 chars). Echoed back in the X-Request-Id response header (a ULID is generated if omitted)."
      schema:
        type: string
        maxLength: 64
  schemas:
    DocTypeRef:
      description: Embedded doc-type summary nested inside a Doc object. Null when no type is set.
      type: object
      required: [id, slug, name]
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        required_properties:
          type: array
          items:
            type: string
          description: "Keys of required properties on this doc type. Present when the type has at least one required property; may be absent or empty when there are none."

    Doc:
      description: "Full doc shape returned by show/create/update responses. Includes body."
      type: object
      required: [id, title, body, properties, body_blob_sha]
      properties:
        id:
          type: integer
        title:
          type: string
        folder_id:
          type: ["integer", "null"]
        folder_path:
          type: ["string", "null"]
        body:
          type: string
        properties:
          description: "Key-value property map. Empty serializes as `[]` (not `{}`) due to PHP JSON encoding of empty arrays."
          oneOf:
            - type: object
              additionalProperties: true
            - type: array
              maxItems: 0
        doc_type:
          oneOf:
            - $ref: '#/components/schemas/DocTypeRef'
            - type: "null"
        current_version_id:
          type: ["integer", "null"]
        body_blob_sha:
          type: string
        created_at:
          type: ["string", "null"]
          format: date-time
        updated_at:
          type: ["string", "null"]
          format: date-time

    DocSummary:
      description: "Summary doc shape returned by list/search responses. Omits body."
      type: object
      required: [id, title, properties, properties_pending]
      properties:
        id:
          type: integer
        title:
          type: string
        folder_id:
          type: ["integer", "null"]
        folder_path:
          type: ["string", "null"]
        properties:
          description: "Key-value property map. Empty serializes as `[]` (not `{}`) due to PHP JSON encoding of empty arrays."
          oneOf:
            - type: object
              additionalProperties: true
            - type: array
              maxItems: 0
        properties_pending:
          type: boolean
        updated_at:
          type: ["string", "null"]
          format: date-time

    Folder:
      description: "Full folder shape returned by show/create/rename/move responses."
      type: object
      required: [id, name]
      properties:
        id:
          type: integer
        name:
          type: string
        parent_folder_id:
          type: ["integer", "null"]
        folder_path:
          type: ["string", "null"]
        created_at:
          type: ["string", "null"]
          format: date-time
        updated_at:
          type: ["string", "null"]
          format: date-time
        deleted_at:
          type: ["string", "null"]
          format: date-time

    DocTypeProperty:
      type: object
      required: [id, position, key, label, kind, required, config]
      properties:
        id:
          type: integer
        position:
          type: integer
        key:
          type: string
        label:
          type: string
        kind:
          type: string
          enum: [text, number, boolean, select, multi_select, date, url, relation]
        required:
          type: boolean
        config:
          description: "Property-kind configuration object (e.g. select options). Empty serializes as `[]` (not `{}`) due to PHP JSON encoding of empty arrays."
          oneOf:
            - type: object
              additionalProperties: true
            - type: array
              maxItems: 0

    DocType:
      description: Full doc-type shape including its ordered property definitions.
      type: object
      required: [id, workspace_id, slug, name, is_builtin, properties]
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        slug:
          type: string
        name:
          type: string
        icon:
          type: ["string", "null"]
        description:
          type: ["string", "null"]
        is_builtin:
          type: boolean
        properties:
          type: array
          items:
            $ref: '#/components/schemas/DocTypeProperty'

    OrderByItem:
      description: "A sort directive: either a bare field name (ascending) or a `{field, direction}` object."
      oneOf:
        - type: string
        - type: object
          required: [field]
          properties:
            field:
              type: string
            direction:
              type: string
              enum: [asc, desc]

    View:
      description: >
        Saved query (view) over docs.

        **`where_clause`** is a keyed hash of filter conditions understood by the ViewRunner:
        `{"field": value}` (equality) or `{"field": {"op": ">", "value": 3}}` (comparison).
        Supported system fields: `title` (op: `contains`), `created_at`/`updated_at`
        (ops: `before`/`after`/`lte`/`gte`/`between`/`last_n_days`), `folder_id` (op: `equals_descendant`).
        Property fields support ops: `=`, `<`, `<=`, `>`, `>=`, `exists`, `contains`, `has_any`, `has_all`.
        Example: `{"status": "draft", "updated_at": {"op": "last_n_days", "value": 7}}`.
        Empty when no filters are saved. NOTE: this is a keyed hash — NOT the
        `{"where": [{"property", "op", "value"}, ...]}` list form used by
        `POST /docs/properties/query`. A list-shaped value is rejected with `422 validation_failed`.

        **`order_by`** is an array of field names (e.g. `["updated_at"]`) or keyed-form objects
        (`[{"field": "updated_at", "direction": "desc"}]`). Bare property names are auto-promoted
        to `properties->>{name}` order.
      type: object
      required: [id, workspace_id, name, render, where_clause]
      properties:
        id:
          type: integer
        workspace_id:
          type: ["integer", "string"]
          description: "UUID string in practice (workspace ids are UUIDs)."
        name:
          type: string
        render:
          type: string
        where_clause:
          description: "Filter conditions keyed by field name. Empty serializes as `[]` (not `{}`) due to PHP JSON encoding of empty arrays."
          oneOf:
            - type: object
              additionalProperties: true
            - type: array
              maxItems: 0
        order_by:
          type: ["array", "null"]
          items:
            $ref: '#/components/schemas/OrderByItem'
          description: "Array of field names or `{field, direction}` objects to sort by. Null when not set. See OrderByItem."
        columns:
          type: ["array", "null"]
          items:
            type: string
        created_at:
          type: ["string", "null"]
          format: date-time
        updated_at:
          type: ["string", "null"]
          format: date-time

    DocVersion:
      description: Full version snapshot including body.
      type: object
      required: [id, doc_id, title, properties, body, body_blob_sha]
      properties:
        id:
          type: integer
        doc_id:
          type: integer
        title:
          type: string
        properties:
          description: "Key-value property map at the time this version was created. Empty serializes as `[]` (not `{}`) due to PHP JSON encoding of empty arrays."
          oneOf:
            - type: object
              additionalProperties: true
            - type: array
              maxItems: 0
        body:
          type: string
        body_blob_sha:
          type: string
        author_id:
          type: ["integer", "null"]
        created_at:
          type: ["string", "null"]
          format: date-time

    DocVersionSummary:
      description: "Version summary (no body) for list responses."
      type: object
      required: [id, doc_id, title]
      properties:
        id:
          type: integer
        doc_id:
          type: integer
        title:
          type: string
        author_id:
          type: ["integer", "null"]
        created_at:
          type: ["string", "null"]
          format: date-time

    DocListResponse:
      description: "Paginated list of doc summaries (no body field)."
      type: object
      required: [data, limit, offset, total]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/DocSummary'
        limit:
          type: integer
        offset:
          type: integer
        total:
          type: integer

    DocResponse:
      description: "Single-doc response wrapper used by create/update/show."
      type: object
      required: [doc, warnings]
      properties:
        doc:
          $ref: '#/components/schemas/Doc'
        warnings:
          type: array
          description: "In-band warnings (e.g. ambiguous doc links). Always present; empty array when none."
          items:
            type: object
            additionalProperties: true

    BulkResultItem:
      type: object
      required: [index, status]
      properties:
        index:
          type: integer
        status:
          type: string
          enum: [created, skipped, error]
        doc:
          description: Present when status is "created".
          $ref: '#/components/schemas/Doc'
        reason:
          description: Present when status is "skipped".
          $ref: '#/components/schemas/Error'
        error:
          description: Present when status is "error".
          $ref: '#/components/schemas/Error'

    BulkSummary:
      type: object
      required: [created, skipped, errors, total]
      properties:
        created:
          type: integer
        skipped:
          type: integer
        errors:
          type: integer
        total:
          type: integer
        folders_created:
          type: integer
          description: Present in bulk-create responses only. Number of intermediate folders created.

    BulkResponse:
      description: >
        Response for bulk create/read endpoints. Always HTTP 200 when the request was structurally
        valid — even if every item errored. Per-item outcomes are in results[].status.
      type: object
      required: [results, summary]
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/BulkResultItem'
        summary:
          $ref: '#/components/schemas/BulkSummary'

    VersionDiffResponse:
      description: >
        Response wrapper for the diff endpoint. The payload is nested under the
        `diff` key. `body` is the unified-diff string produced by BodyDiffer;
        `properties` contains added/removed/changed keys from PropertyDiffer.
      type: object
      required: [diff]
      properties:
        diff:
          type: object
          required: [from_version, to_version, body, properties]
          properties:
            from_version:
              type: integer
            to_version:
              description: "Version id (as string) or the literal string \"current\"."
              type: string
            body:
              type: string
              description: Unified-diff string for the body.
            properties:
              type: object
              description: "Property-level diff with added/removed/changed keys."
              properties:
                added:
                  type: object
                  additionalProperties: true
                removed:
                  type: object
                  additionalProperties: true
                changed:
                  type: object
                  additionalProperties: true

    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: "Stable lower_snake_case error code. Switch on this, not on `message`."
          enum:
            - duplicate_title
            - folder_not_in_workspace
            - folder_path_not_found
            - required_properties_missing
            - invalid_property_values
            - invalid_frontmatter
            - stale_draft
            - no_draft
            - builtin_immutable
            - key_in_use
            - type_in_use
            - slug_reserved
            - version_not_for_doc
            - cross_surface_link
            - blob_too_large
            - blob_not_found
            - idempotency_key_conflict
            - not_found
            - unauthenticated
            - forbidden
            - validation_failed
            - server_error
            - duplicate_folder_name
            - folder_cycle
            - find_not_in_body
            - heading_not_found
            - type_not_found
        message:
          type: string
        details:
          type: object
          additionalProperties: true

    ValidationError:
      description: "Error with field-level validation details (code = validation_failed)."
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            details:
              type: object
              required: [errors]
              description: "Validation failures are nested under `errors`, keyed by field name."
              properties:
                errors:
                  type: object
                  description: "Keyed by field name; each value is an array of error message strings."
                  additionalProperties:
                    type: array
                    items:
                      type: string

  responses:
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    Forbidden:
      description: Authenticated but not permitted.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    ValidationFailed:
      description: Request payload failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationError'


paths:

  # ---------------------------------------------------------------------------
  # Docs — core
  # ---------------------------------------------------------------------------

  /docs:
    get:
      tags: [Docs]
      operationId: listDocs
      summary: List docs in a workspace (optionally filtered by folder)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: folder_id
          in: query
          required: false
          schema:
            type: integer
          description: Filter to a specific folder by id. Takes precedence over folder_path.
        - name: folder_path
          in: query
          required: false
          schema:
            type: string
          description: Filter to a specific folder by path string. Ignored when folder_id is present.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Paginated list of doc summaries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

    post:
      tags: [Docs]
      operationId: createDoc
      summary: Create a new doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  maxLength: 255
                body:
                  type: ["string", "null"]
                  description: Initial body content (Markdown). Capped at the server's configured blob size limit (default 1 MB).
                properties:
                  type: ["object", "null"]
                  additionalProperties: true
                folder_path:
                  type: ["string", "null"]
                folder_id:
                  type: ["integer", "null"]
                doc_type_id:
                  type: ["integer", "null"]
                  description: "Doc type id (integer) to attach on create. Takes precedence over `type` if both are sent."
                type:
                  type: ["string", "null"]
                  description: "Doc type slug (or integer id) to attach on create. An unrecognized value returns 422 `type_not_found` and the doc is not created. If both `type` and `doc_type_id` are sent, `doc_type_id` wins."
                parse_frontmatter:
                  type: ["boolean", "null"]
                  description: When true, frontmatter in body is extracted as properties.
                create_missing_folders:
                  type: ["boolean", "null"]
                  description: When true, intermediate folders named in folder_path are created automatically.
      responses:
        '201':
          description: Doc created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          description: "Body blob exceeds size limit (`blob_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `duplicate_title`, `folder_not_in_workspace`, `folder_path_not_found`, `required_properties_missing`, `invalid_property_values`, `invalid_frontmatter`, `type_not_found`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /docs/by-path:
    get:
      tags: [Docs]
      operationId: getDocByPath
      summary: Look up a doc by title and optional folder path
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: title
          in: query
          required: true
          schema:
            type: string
            maxLength: 255
        - name: folder_path
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Doc found.
          content:
            application/json:
              schema:
                type: object
                required: [doc]
                properties:
                  doc:
                    $ref: '#/components/schemas/Doc'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/search:
    get:
      tags: [Docs]
      operationId: searchDocs
      summary: Full-text search over docs in a workspace
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: query
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 500
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: Matching doc summaries (no body field). Not paginated — all results returned up to limit.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/{doc}:
    get:
      tags: [Docs]
      operationId: getDoc
      summary: Get a single doc by id
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Doc found.
          content:
            application/json:
              schema:
                type: object
                required: [doc]
                properties:
                  doc:
                    $ref: '#/components/schemas/Doc'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [Docs]
      operationId: deleteDoc
      summary: Soft-delete a doc (moves to trash)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      responses:
        '204':
          description: Doc deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /docs/{doc}/rename:
    post:
      tags: [Docs]
      operationId: renameDoc
      summary: Rename a doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_title]
              properties:
                new_title:
                  type: string
                  maxLength: 255
      responses:
        '200':
          description: Doc renamed. Returns updated doc with doc-link warnings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `duplicate_title`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /docs/{doc}/move:
    post:
      tags: [Docs]
      operationId: moveDoc
      summary: Move a doc to a different folder
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "Provide new_folder_id or new_folder_path (at least one is required)."
              properties:
                new_folder_id:
                  type: ["integer", "null"]
                  description: Target folder id. Required when new_folder_path is absent.
                new_folder_path:
                  type: ["string", "null"]
                  description: Target folder path string. Required when new_folder_id is absent.
      responses:
        '200':
          description: Doc moved.
          content:
            application/json:
              schema:
                type: object
                required: [doc]
                properties:
                  doc:
                    $ref: '#/components/schemas/Doc'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `duplicate_title`, `folder_not_in_workspace`, `folder_path_not_found`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /docs/{doc}/restore:
    post:
      tags: [Docs]
      operationId: restoreDoc
      summary: Restore a soft-deleted doc from trash
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Doc restored.
          content:
            application/json:
              schema:
                type: object
                required: [doc]
                properties:
                  doc:
                    $ref: '#/components/schemas/Doc'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ---------------------------------------------------------------------------
  # Doc Body & Properties
  # ---------------------------------------------------------------------------

  /docs/{doc}/body:
    put:
      tags: [Doc Body & Properties]
      operationId: setDocBody
      summary: Replace the entire body of a doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                  description: Full replacement body. Capped at the server's configured blob size limit (default 1 MB).
      responses:
        '200':
          description: Body replaced.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: "Conflict. Possible `code` values: `stale_draft` (If-Match etag mismatch), `idempotency_key_conflict`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: "Body blob exceeds size limit (`blob_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/{doc}/append:
    post:
      tags: [Doc Body & Properties]
      operationId: appendDocBody
      summary: Append content to the end of a doc's body
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  description: Content to append. Capped at the server's configured blob size limit (default 1 MB).
      responses:
        '200':
          description: Content appended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: "Combined body exceeds size limit (`blob_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/{doc}/prepend:
    post:
      tags: [Doc Body & Properties]
      operationId: prependDocBody
      summary: Prepend content to the beginning of a doc's body
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  description: Content to prepend. Capped at the server's configured blob size limit (default 1 MB).
      responses:
        '200':
          description: Content prepended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: "Combined body exceeds size limit (`blob_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/{doc}/window:
    post:
      tags: [Doc Body & Properties]
      operationId: windowDocBody
      summary: Find-and-replace a substring window in a doc's body
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [find, replace]
              properties:
                find:
                  type: string
                  minLength: 1
                  description: Exact substring to locate in the body.
                replace:
                  type: string
                  description: Replacement string (may be empty to delete the window).
      responses:
        '200':
          description: Window replaced.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: "Combined body exceeds size limit (`blob_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `find_not_in_body`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /docs/{doc}/section:
    post:
      tags: [Doc Body & Properties]
      operationId: sectionDocBody
      summary: Replace the content under a named heading in a doc's body
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [heading, new_content]
              properties:
                heading:
                  type: string
                  maxLength: 1024
                  description: Heading text (without leading `#`).
                new_content:
                  type: string
                  description: Replacement section body. Capped at the server's configured blob size limit (default 1 MB).
      responses:
        '200':
          description: Section replaced.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: "Combined body exceeds size limit (`blob_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `heading_not_found`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /docs/{doc}/properties:
    put:
      tags: [Doc Body & Properties]
      operationId: replaceDocProperties
      summary: Replace all properties on a doc (full replace)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [properties]
              properties:
                properties:
                  type: object
                  additionalProperties: true
                  description: New complete set of properties. Existing properties not in this object are removed.
      responses:
        '200':
          description: Properties replaced.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `required_properties_missing`, `invalid_property_values`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    patch:
      tags: [Doc Body & Properties]
      operationId: mergeDocProperties
      summary: Merge properties into a doc (shallow merge)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [properties]
              properties:
                properties:
                  type: object
                  additionalProperties: true
                  description: Properties to merge in. Existing keys not in this object are preserved.
      responses:
        '200':
          description: Properties merged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `required_properties_missing`, `invalid_property_values`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /docs/{doc}/type:
    put:
      tags: [Doc Body & Properties]
      operationId: setDocType
      summary: Set or clear the doc type on a doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  description: "Doc type slug (string), integer id, or null to clear the type."
                  oneOf:
                    - type: string
                    - type: integer
                    - type: "null"
      responses:
        '200':
          description: Doc type updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `type_not_found`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ---------------------------------------------------------------------------
  # Versions
  # ---------------------------------------------------------------------------

  /docs/{doc}/versions:
    get:
      tags: [Versions]
      operationId: listDocVersions
      summary: List version history for a doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Paginated list of version summaries (no body field).
          content:
            application/json:
              schema:
                type: object
                required: [data, limit, offset, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocVersionSummary'
                  limit:
                    type: integer
                  offset:
                    type: integer
                  total:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/{doc}/versions/{version}:
    get:
      tags: [Versions]
      operationId: getDocVersion
      summary: Get a single version snapshot (includes body)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
        - name: version
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Version snapshot.
          content:
            application/json:
              schema:
                type: object
                required: [version]
                properties:
                  version:
                    $ref: '#/components/schemas/DocVersion'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /docs/{doc}/versions/{version}/diff:
    get:
      tags: [Versions]
      operationId: diffDocVersion
      summary: Diff a stored version against another version or the current doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
        - name: version
          in: path
          required: true
          schema:
            type: integer
        - name: against
          in: query
          required: false
          description: "Integer version id to diff against, or the string \"current\" (default) to diff against the live doc."
          schema:
            oneOf:
              - type: integer
              - type: string
                enum: [current]
            default: current
      responses:
        '200':
          description: Diff result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VersionDiffResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `version_not_for_doc`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /docs/{doc}/versions/{version}/rollback:
    post:
      tags: [Versions]
      operationId: rollbackDocVersion
      summary: Roll the doc back to a stored version
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
        - name: version
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Doc rolled back. Returns the updated doc.
          content:
            application/json:
              schema:
                type: object
                required: [doc]
                properties:
                  doc:
                    $ref: '#/components/schemas/Doc'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ---------------------------------------------------------------------------
  # Folders
  # ---------------------------------------------------------------------------

  /folders:
    get:
      tags: [Folders]
      operationId: listFolders
      summary: List folders in a workspace (paginated)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Paginated list of folders.
          content:
            application/json:
              schema:
                type: object
                required: [data, limit, offset, total]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Folder'
                  limit:
                    type: integer
                  offset:
                    type: integer
                  total:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

    post:
      tags: [Folders]
      operationId: createFolder
      summary: Create a new folder
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 255
                parent_folder_id:
                  type: ["integer", "null"]
                  description: Parent folder by id. Takes precedence over parent_folder_path when both are present.
                parent_folder_path:
                  type: ["string", "null"]
                  description: Parent folder by path string. Ignored when parent_folder_id is present.
      responses:
        '201':
          description: Folder created.
          content:
            application/json:
              schema:
                type: object
                required: [folder]
                properties:
                  folder:
                    $ref: '#/components/schemas/Folder'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `duplicate_folder_name`, `folder_not_in_workspace`, `folder_path_not_found`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /folders/bulk:
    post:
      tags: [Folders]
      operationId: bulkCreateFolders
      summary: Bulk-create folders by path strings (up to 200)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [paths]
              properties:
                paths:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items:
                    type: string
                    maxLength: 1024
      responses:
        '200':
          description: Bulk folder creation result. Always HTTP 200 when the request was structurally valid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /folders/{folder}:
    get:
      tags: [Folders]
      operationId: getFolder
      summary: Get a single folder by id
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: folder
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Folder found.
          content:
            application/json:
              schema:
                type: object
                required: [folder]
                properties:
                  folder:
                    $ref: '#/components/schemas/Folder'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [Folders]
      operationId: deleteFolder
      summary: Soft-delete a folder (moves to trash)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: folder
          in: path
          required: true
          schema:
            type: integer
      responses:
        '204':
          description: Folder deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /folders/{folder}/rename:
    post:
      tags: [Folders]
      operationId: renameFolder
      summary: Rename a folder
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: folder
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_name]
              properties:
                new_name:
                  type: string
                  maxLength: 255
      responses:
        '200':
          description: Folder renamed.
          content:
            application/json:
              schema:
                type: object
                required: [folder]
                properties:
                  folder:
                    $ref: '#/components/schemas/Folder'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `duplicate_folder_name`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /folders/{folder}/move:
    post:
      tags: [Folders]
      operationId: moveFolder
      summary: Move a folder to a new parent
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: folder
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "Provide exactly one of new_parent_folder_id or new_parent_folder_path. Pass new_parent_folder_id: null to move the folder to the workspace root."
              properties:
                new_parent_folder_id:
                  type: ["integer", "null"]
                  description: Target parent folder id. Pass null to move to root. Required when new_parent_folder_path is absent.
                new_parent_folder_path:
                  type: ["string", "null"]
                  description: Target parent folder path string. Required when new_parent_folder_id is absent.
      responses:
        '200':
          description: Folder moved.
          content:
            application/json:
              schema:
                type: object
                required: [folder]
                properties:
                  folder:
                    $ref: '#/components/schemas/Folder'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `duplicate_folder_name`, `folder_cycle`, `folder_not_in_workspace`, `folder_path_not_found`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /folders/{folder}/restore:
    post:
      tags: [Folders]
      operationId: restoreFolder
      summary: Restore a soft-deleted folder from trash
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: folder
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Folder restored.
          content:
            application/json:
              schema:
                type: object
                required: [folder]
                properties:
                  folder:
                    $ref: '#/components/schemas/Folder'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ---------------------------------------------------------------------------
  # Doc Types
  # ---------------------------------------------------------------------------

  /doc-types:
    get:
      tags: [Doc Types]
      operationId: listDocTypes
      summary: List all doc types (built-in and custom) in a workspace
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: All doc types (built-in first, then custom).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DocType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [Doc Types]
      operationId: createDocType
      summary: Create a custom doc type
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, name]
              properties:
                slug:
                  type: string
                  maxLength: 64
                name:
                  type: string
                  maxLength: 255
                description:
                  type: ["string", "null"]
                  maxLength: 1024
                icon:
                  type: ["string", "null"]
                  maxLength: 32
                properties:
                  type: ["array", "null"]
                  items:
                    type: object
                    required: [position, key, kind]
                    properties:
                      position:
                        type: integer
                      key:
                        type: string
                        maxLength: 64
                      label:
                        type: ["string", "null"]
                        maxLength: 255
                      kind:
                        type: string
                        enum: [text, number, boolean, select, multi_select, date, url, relation]
                      required:
                        type: ["boolean", "null"]
                      config:
                        type: ["object", "null"]
                        additionalProperties: true
      responses:
        '201':
          description: Doc type created.
          content:
            application/json:
              schema:
                type: object
                required: [doc_type]
                properties:
                  doc_type:
                    $ref: '#/components/schemas/DocType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `slug_reserved`, `key_in_use`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /doc-types/{docType}:
    get:
      tags: [Doc Types]
      operationId: getDocType
      summary: Get a single doc type by id
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: docType
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Doc type found.
          content:
            application/json:
              schema:
                type: object
                required: [doc_type]
                properties:
                  doc_type:
                    $ref: '#/components/schemas/DocType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Doc Types]
      operationId: updateDocType
      summary: Update a custom doc type
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: docType
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                slug:
                  type: ["string", "null"]
                  maxLength: 64
                name:
                  type: ["string", "null"]
                  maxLength: 255
                description:
                  type: ["string", "null"]
                  maxLength: 1024
                icon:
                  type: ["string", "null"]
                  maxLength: 32
                properties:
                  type: ["array", "null"]
                  description: Full replacement set of property definitions. When present, replaces all existing properties.
                  items:
                    type: object
                    required: [position, key, kind]
                    properties:
                      position:
                        type: integer
                      key:
                        type: string
                        maxLength: 64
                      label:
                        type: ["string", "null"]
                        maxLength: 255
                      kind:
                        type: string
                        enum: [text, number, boolean, select, multi_select, date, url, relation]
                      required:
                        type: ["boolean", "null"]
                      config:
                        type: ["object", "null"]
                        additionalProperties: true
      responses:
        '200':
          description: Doc type updated.
          content:
            application/json:
              schema:
                type: object
                required: [doc_type]
                properties:
                  doc_type:
                    $ref: '#/components/schemas/DocType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Validation or domain error. Possible `code` values: `validation_failed`, `slug_reserved`, `key_in_use`, `builtin_immutable`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    delete:
      tags: [Doc Types]
      operationId: deleteDocType
      summary: Delete a custom doc type
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: docType
          in: path
          required: true
          schema:
            type: integer
      responses:
        '204':
          description: Doc type deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "Domain error. Possible `code` values: `type_in_use` (doc type is still used by docs), `builtin_immutable` (cannot delete built-in types)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ---------------------------------------------------------------------------
  # Views
  # ---------------------------------------------------------------------------

  /views:
    get:
      tags: [Views]
      operationId: listViews
      summary: List all views in a workspace
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: All views for the workspace.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/View'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [Views]
      operationId: createView
      summary: Create or upsert a view
      description: "When `id` is provided the view is updated (200); when omitted it is created (201)."
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                id:
                  type: ["integer", "null"]
                  description: When provided, updates the view with this id rather than creating a new one.
                name:
                  type: string
                  maxLength: 255
                render:
                  type: ["string", "null"]
                  enum: [table, list]
                where_clause:
                  description: >
                    Filter conditions as a keyed hash. Keys are field names; values are scalars (equality)
                    or `{op, value}` objects (comparison). System fields: `title` (`contains`),
                    `created_at`/`updated_at` (`before`/`after`/`lte`/`gte`/`between`/`last_n_days`),
                    `folder_id` (`equals_descendant`). Property fields: `=`, `<`, `<=`, `>`, `>=`,
                    `exists`, `contains`, `has_any`, `has_all`. Example: `{"status": "draft"}`.
                    Omit or pass null for no filters.
                  type: ["object", "null"]
                  additionalProperties: true
                order_by:
                  description: "Array of field names or `{field, direction}` objects. Example: `[\"updated_at\"]` or `[{\"field\": \"updated_at\", \"direction\": \"desc\"}]`."
                  type: ["array", "null"]
                  items:
                    $ref: '#/components/schemas/OrderByItem'
                columns:
                  type: ["array", "null"]
                  items:
                    type: string
      responses:
        '201':
          description: View created.
          content:
            application/json:
              schema:
                type: object
                required: [view]
                properties:
                  view:
                    $ref: '#/components/schemas/View'
        '200':
          description: View updated (when id was provided).
          content:
            application/json:
              schema:
                type: object
                required: [view]
                properties:
                  view:
                    $ref: '#/components/schemas/View'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /views/{view}:
    get:
      tags: [Views]
      operationId: getView
      summary: Get a single view by id
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: view
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: View found.
          content:
            application/json:
              schema:
                type: object
                required: [view]
                properties:
                  view:
                    $ref: '#/components/schemas/View'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [Views]
      operationId: deleteView
      summary: Delete a view
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: view
          in: path
          required: true
          schema:
            type: integer
      responses:
        '204':
          description: View deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /views/{view}/run:
    post:
      tags: [Views]
      operationId: runView
      summary: Execute a view and return matching doc stubs
      description: >
        Executes the view's `where_clause` filter and returns a flat list of doc stubs.
        Each stub includes `id`, `title`, `folder_id`, and `properties`. When the view
        has a `columns` filter set, only those property keys are included; otherwise all
        properties are returned. The `properties` field serializes as `[]` (not `{}`)
        when the doc has no properties (PHP empty-array JSON encoding). The `total` field
        reflects the unfiltered count of matching docs (before any in-memory column filtering).
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: view
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: View executed. Returns matching doc stubs and total count.
          content:
            application/json:
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, title, properties]
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        properties:
                          description: "Filtered property map (subset if view has columns set). Empty serializes as `[]` (not `{}`)."
                          oneOf:
                            - type: object
                              additionalProperties: true
                            - type: array
                              maxItems: 0
                        folder_id:
                          type: ["integer", "null"]
                  total:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ---------------------------------------------------------------------------
  # Trash
  # ---------------------------------------------------------------------------

  /trash:
    get:
      tags: [Trash]
      operationId: listTrash
      summary: List soft-deleted docs and folders in a workspace
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 200
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Soft-deleted docs and folders.
          content:
            application/json:
              schema:
                type: object
                required: [folders, docs]
                properties:
                  folders:
                    type: array
                    items:
                      type: object
                      required: [id, name]
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        parent_folder_id:
                          type: ["integer", "null"]
                        delete_token:
                          type: ["string", "null"]
                        deleted_at:
                          type: ["string", "null"]
                          format: date-time
                  docs:
                    type: array
                    items:
                      type: object
                      required: [id, title]
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        folder_id:
                          type: ["integer", "null"]
                        delete_token:
                          type: ["string", "null"]
                        deleted_at:
                          type: ["string", "null"]
                          format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  # ---------------------------------------------------------------------------
  # Graph
  # ---------------------------------------------------------------------------

  /docs/graph/unresolved:
    get:
      tags: [Graph]
      operationId: listUnresolvedLinks
      summary: List doc links that point to non-existent docs
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Unresolved doc links.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [from_doc_id, anchor_text]
                      properties:
                        from_doc_id:
                          type: integer
                        from_doc_title:
                          type: ["string", "null"]
                        anchor_text:
                          type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /docs/graph/stale-anchor-texts:
    get:
      tags: [Graph]
      operationId: listStaleAnchorTexts
      summary: List doc links whose anchor text no longer matches the target doc title
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Stale-anchor-text doc links.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [from_doc_id, to_doc_id, anchor_text]
                      properties:
                        from_doc_id:
                          type: integer
                        from_doc_title:
                          type: ["string", "null"]
                        to_doc_id:
                          type: integer
                        current_to_title:
                          type: ["string", "null"]
                        anchor_text:
                          type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /docs/{doc}/backlinks:
    get:
      tags: [Graph]
      operationId: listBacklinks
      summary: List doc links pointing to a specific doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Backlinks for the doc.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [from_doc_id, anchor_text]
                      properties:
                        from_doc_id:
                          type: integer
                        from_doc_title:
                          type: ["string", "null"]
                        anchor_text:
                          type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /docs/{doc}/links:
    get:
      tags: [Graph]
      operationId: listLinks
      summary: List doc links originating from a specific doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Outgoing doc links from the doc.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [anchor_text]
                      properties:
                        to_doc_id:
                          type: ["integer", "null"]
                        to_doc_title:
                          type: ["string", "null"]
                        anchor_text:
                          type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ---------------------------------------------------------------------------
  # Properties
  # ---------------------------------------------------------------------------

  /docs/properties/distinct:
    get:
      tags: [Docs]
      operationId: listDistinctProperties
      summary: List distinct values for a named property across all docs
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: property
          in: query
          required: true
          schema:
            type: string
            maxLength: 255
      responses:
        '200':
          description: Distinct property values with per-value doc counts.
          content:
            application/json:
              schema:
                type: object
                required: [property, values, doc_counts]
                properties:
                  property:
                    type: string
                  values:
                    type: array
                    items: {}
                    description: Ordered list of distinct values for the property.
                  doc_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: Map of value (as string) to doc count.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/properties/query:
    post:
      tags: [Docs]
      operationId: queryDocsByProperties
      summary: Query docs by property conditions
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [where]
              properties:
                where:
                  type: array
                  description: Array of property filter conditions (ANDed together).
                  items:
                    type: object
                    required: [property, op, value]
                    properties:
                      property:
                        type: string
                        maxLength: 255
                      op:
                        type: string
                        enum: ["=", "!=", ">", "<", ">=", "<=", "in", "not_in"]
                      value:
                        description: The value to compare against. Required (may be null).
                limit:
                  type: ["integer", "null"]
                  minimum: 1
                  maximum: 200
                  default: 50
                offset:
                  type: ["integer", "null"]
                  minimum: 0
                  default: 0
      responses:
        '200':
          description: Matching doc summaries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  # ---------------------------------------------------------------------------
  # Bulk
  # ---------------------------------------------------------------------------

  /docs/bulk:
    post:
      tags: [Bulk]
      operationId: bulkCreateDocs
      summary: Bulk-create docs (up to 200 items)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items:
                    type: object
                    required: [title]
                    properties:
                      title:
                        type: string
                        maxLength: 255
                      body:
                        type: ["string", "null"]
                      properties:
                        type: ["object", "null"]
                        additionalProperties: true
                      folder_path:
                        type: ["string", "null"]
                        description: Folder path for this item, relative to folder_path when both are present.
                      relative_folder_path:
                        type: ["string", "null"]
                        description: Relative folder path appended to the top-level folder_path.
                      type:
                        type: ["string", "null"]
                        description: Doc type slug override for this item.
                folder_path:
                  type: ["string", "null"]
                  description: Base folder path applied to all items (items may override per-item).
                on_conflict:
                  type: ["string", "null"]
                  enum: [skip, overwrite, rename]
                  default: skip
                parse_frontmatter:
                  type: ["boolean", "null"]
                  description: When true, frontmatter in body is extracted as properties.
                type:
                  type: ["string", "null"]
                  description: Default doc type slug applied to all items (items may override per-item).
                dry_run:
                  type: ["boolean", "null"]
                  description: When true, validates and previews without persisting.
      responses:
        '200':
          description: "Bulk create result. Always HTTP 200 when structurally valid — even if every item errored. Per-item outcomes are in results[].status."
          content:
            application/json:
              schema:
                type: object
                required: [results, summary]
                properties:
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/BulkResultItem'
                  summary:
                    $ref: '#/components/schemas/BulkSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/bulk-read:
    post:
      tags: [Bulk]
      operationId: bulkReadDocs
      summary: Bulk-read docs by id or path (up to 200 items)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 200
                  description: Each item must supply exactly one of id or path.
                  items:
                    type: object
                    properties:
                      id:
                        type: ["integer", "null"]
                        description: Look up by doc id. Mutually exclusive with path.
                      path:
                        type: ["object", "null"]
                        description: Look up by folder path + title. Mutually exclusive with id.
                        properties:
                          folder_path:
                            type: ["string", "null"]
                          title:
                            type: string
                            description: Required when path is present.
      responses:
        '200':
          description: "Bulk read result. Always HTTP 200 when structurally valid. Per-item outcomes are in results[].status."
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/BulkResultItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/media:
    post:
      tags: [Media]
      operationId: uploadMedia
      summary: Upload a binary file as a media doc (multipart/form-data)
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: "The binary file. Capped at the server's configured media size limit (default 20 MB)."
                title:
                  type: ["string", "null"]
                  maxLength: 255
                  description: Doc title. Defaults to the client filename when omitted.
                folder_path:
                  type: ["string", "null"]
                  description: Destination folder path. Missing folders are created automatically.
      responses:
        '201':
          description: "Media doc created. The file pointer (mime, size, blob_sha, width, height) lives in the doc's properties; the body is empty."
          content:
            application/json:
              schema:
                type: object
                required: [doc]
                properties:
                  doc:
                    $ref: '#/components/schemas/Doc'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          description: "File exceeds the media size limit (`media_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /docs/{doc}/media:
    get:
      tags: [Media]
      operationId: getMediaSignedUrl
      summary: Get a short-lived signed URL and metadata for a media doc
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Signed URL (valid ~10 minutes) plus media metadata.
          content:
            application/json:
              schema:
                type: object
                required: [url, mime, size]
                properties:
                  url:
                    type: string
                    description: Short-lived signed URL for downloading the file directly from object storage.
                  mime:
                    type: string
                  size:
                    type: integer
                  width:
                    type: ["integer", "null"]
                  height:
                    type: ["integer", "null"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: "Doc not found, doc is not a media doc, or the underlying object is gone (`media_not_found`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags: [Media]
      operationId: replaceMedia
      summary: Replace an existing media doc's bytes in place (multipart/form-data)
      description: |
        Swaps the bytes behind an existing media doc, keeping its id, title, and folder — so
        embeds and references keep resolving. Bumps the doc's revision, so rolling back via
        `POST /docs/{doc}/versions/{version}/rollback` restores the previous image.

        POST rather than PUT: PHP never populates `$_FILES` for a `PUT` request, so a multipart
        `PUT` body cannot be parsed.

        This is the only REST write with an opt-in drift guard. Pass `base_revision` to make the
        write conditional; see docs/docs-concurrency.md.
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
        - name: doc
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: "The replacement bytes. Capped at the server's configured media size limit (default 20 MB)."
                base_revision:
                  type: ["integer", "null"]
                  description: "When supplied, the write is rejected with `409 stale_revision` if the doc's head has moved past this revision. Omit for last-write-wins."
      responses:
        '200':
          description: "Media replaced. The doc's id, title, and folder are unchanged; `current_version_id` is the new revision."
          content:
            application/json:
              schema:
                type: object
                required: [doc]
                properties:
                  doc:
                    $ref: '#/components/schemas/Doc'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: "Doc not found or is not a media doc (`media_not_found`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: "The doc changed since `base_revision` (`stale_revision`). The response body carries `current_revision_id`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: "File exceeds the media size limit (`media_too_large`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: "`base_revision` names no revision on this doc (`base_revision_not_found`), or validation failed."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
