openapi: 3.0.3
info:
  title: Hub API
  version: "2.0.0"
  description: >
    Complete, current coverage of `api.php` — every resource/action Hub's
    HTTP API implements, as of the parity pass that produced this version
    (Hub Task #10086, following the Hub Agent API & Capability Guide audit,
    commit `bbf86d1`). Previous versions of this file covered only 5 of the
    20 implemented resources ("Phase 1: Projects, Tasks, Ideas, Search");
    this version brings every resource to parity.


    **This spec's own version number (2.0.0) describes the spec document,
    not the API.** The live HTTP API itself is unversioned — there is no
    `/v1/` path, no version header, and no `Accept` media-type versioning
    anywhere in `api.php`. See
    [hub-agent-api-capability-guide.md §16](hub-agent-api-capability-guide.md#16-versioning-and-compatibility)
    for the authoritative compatibility/versioning discussion; this spec
    does not repeat it.


    **Canonical human-readable reference:**
    [docs/hub-agent-api-capability-guide.md](hub-agent-api-capability-guide.md)
    — read that first for architecture, the MCP tool catalog, activity-log
    semantics, and the full capability matrix. This file exists to be an
    accurate, machine-readable reflection of the same implemented surface,
    not a second, independently-maintained description of it. Where the two
    ever disagree, treat it as documentation drift to fix, not as license to
    pick either — both are meant to describe the same `api.php`.


    **Authentication detail:**
    [docs/api-auth-guide.md](api-auth-guide.md) — request/response shapes,
    error codes, and optimistic-concurrency mechanics in full. This file's
    `securitySchemes` below describe the same three methods (full-access
    API key, scoped API key, session cookie) at the level OpenAPI tooling
    needs; api-auth-guide.md remains authoritative for prose detail.


    **What this spec deliberately does NOT cover, and why:**
    - **Team/user/company administration** (`admin.php`, `team.php`) — not
      exposed through `api.php` at all. No path in this spec reaches user
      management, tenant-admin grants, module-entitlement changes, or
      company suspension — those require a browser session at the
      page-rendered admin surfaces, which are outside `api.php`'s dispatch
      table entirely and therefore outside this spec's scope by
      construction.
    - **Platform-owner tenant protection** (Hub Task #10084) — the
      invariant that company #1 (ISCD) can never be suspended is enforced
      inside `admin.php`'s mutation handler
      (`hub_update_company_status()`), not through any `api.php` resource.
      It is not, and was never meant to be, a capability this HTTP API
      exposes — see
      [hub-agent-api-capability-guide.md §5.3/§5.4](hub-agent-api-capability-guide.md#53-platform-admin-only-capabilities)
      for the actual mechanism.
    - **Public document-share viewing** (`share.php`, `share-beacon.php`,
      `share-attachment.php`) — a structurally separate, unauthenticated,
      token-gated surface that is not part of `/api.php`'s path/resource
      space at all (see
      [hub-agent-api-capability-guide.md §12](hub-agent-api-capability-guide.md#12-sharing-and-public-access)).
      Only the *authenticated management* of shares (`document_shares`,
      `document_share_views` below) lives in `api.php` and appears here.
    - **The Hub MCP server's OAuth 2.1 layer** — governs who may open a
      connection to the separate MCP adapter process; it is not `api.php`
      authentication and does not appear in this file's `securitySchemes`.
      Every MCP tool call still authenticates to `api.php` using one of the
      three methods documented below (in practice, always a scoped
      `ai_agent` API key) — see
      [hub-agent-api-capability-guide.md §3.3](hub-agent-api-capability-guide.md#33-oauth-21-for-the-mcp-server-remoteremotepublic-clients)
      for that distinction in full.


    Every operation is company-scoped automatically from the caller's
    credential — there is no `company_id` parameter anywhere in this spec,
    and none is ever accepted from client input.


    Path keys embed the literal `resource`/`action` query string (e.g.
    `/api.php?resource=projects&action=list`) rather than using a REST-style
    templated path — Hub's router is resource/action-based, not path-based,
    so this is the standard workaround for representing distinct RPC-style
    operations in OpenAPI's path-keyed object model. Every operation has an
    explicit `operationId` for clients (like OpenAI Actions) that key off of
    it rather than the path string.
servers:
  - url: https://dev.innovativesolutionscd.com/hub
  - url: https://hub.justwhatsneeded.com

security:
  - ApiKeyAuth: []
  - SessionCookie: []

paths:
  # ══════════════════════════════════════════════════════════════
  # PROJECTS — no API-key scope allows this resource; full-access key
  # or session only. Module gate: 'projects'.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=projects&action=list:
    get:
      operationId: projectsList
      summary: List projects
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Projects]
      parameters:
        - name: show_all
          in: query
          schema: { type: boolean }
          description: Include hidden (display=0) projects. Default false.
      responses:
        "200":
          description: Array of projects, ordered by updated_at DESC
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Project" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=projects&action=get:
    get:
      operationId: projectsGet
      summary: Read a single project
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Projects]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200":
          description: The project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=projects&action=get-with-context:
    get:
      operationId: projectsGetWithContext
      summary: Read a project bundled with its tasks and recent AI interactions
      description: >
        Scopes — full-access key or session only. Bundles the project row
        with ALL of its tasks (ordered priority DESC, due_date ASC — not
        paginated) and its 10 most recent rows from the separate
        `interactions` database. Not idempotent in a meaningful sense (it's
        a read), but note the interactions sub-query hits a different
        database than `hub` — a project with no interactions logged simply
        gets an empty array, never an error.


        **The `interactions` resource itself was retired (Hub Task
        #10088)** — nothing can write new rows anymore, so this embed can
        only ever surface the 3 pre-retirement historical rows (all
        belonging to project #18). This sub-query was always
        project-scoped and safe; it was never part of the retirement's
        security rationale, only the standalone `interactions.list`/
        `create` actions were.
      tags: [Projects]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200":
          description: Project with embedded tasks and interactions
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Project" }
                  - type: object
                    properties:
                      tasks: { type: array, items: { $ref: "#/components/schemas/Task" } }
                      interactions: { type: array, items: { $ref: "#/components/schemas/Interaction" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=projects&action=create:
    post:
      operationId: projectsCreate
      summary: Create a project
      description: Scopes — full-access key or session only.
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
                status: { type: string, enum: [active, on-hold, completed, archived], default: active }
                client: { type: string, nullable: true, description: "Free-text client label — not a foreign key to any organization/contact entity today." }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=projects&action=update:
    post:
      operationId: projectsUpdate
      summary: Update a project
      description: >
        Scopes — full-access key or session only. Partial update — only
        supplied fields change. No optimistic-concurrency `version` field
        exists on `projects` (unlike tasks/documents/songs); concurrent
        edits are not detected.
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                name: { type: string }
                description: { type: string, nullable: true }
                status: { type: string, enum: [active, on-hold, completed, archived] }
                client: { type: string, nullable: true }
      responses:
        "200":
          description: Confirmation only — does not return the updated row
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=projects&action=hide:
    post:
      operationId: projectsHide
      summary: Toggle a project's display flag (soft hide from the default list view)
      description: Scopes — full-access key or session only. Does not delete anything — only affects whether `projects.list` returns it by default.
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                display: { type: boolean, default: false }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string }, display: { type: integer } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=projects&action=delete:
    post:
      operationId: projectsDelete
      summary: Delete a project (hard delete, cascades)
      description: >
        Scopes — full-access key or session only. **Destructive and
        irreversible.** Hard-deletes the project's tasks and every comment
        on those tasks, then the project row itself, in that order. No
        soft-delete/restore exists for projects (unlike documents/songs).
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=projects&action=get-order:
    get:
      operationId: projectsGetOrder
      summary: Read the current session user's saved project sort order
      description: >
        Scopes — full-access key or session only. **Session-identity
        dependent**: returns an empty array for any caller with no
        resolved `user_id` (a pure API-key call with no accompanying
        session cookie), rather than an error — this endpoint has no
        meaningful result for a caller with no personal ordering to read.
      tags: [Projects]
      responses:
        "200":
          description: Array of {project_id, sort_order}, or empty if no user_id
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties: { project_id: { type: integer }, sort_order: { type: integer } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api.php?resource=projects&action=reorder:
    post:
      operationId: projectsReorder
      summary: Save the current session user's project sort order
      description: >
        Scopes — full-access key or session only. **Requires a resolved
        session `user_id`** — a pure API-key call with no session cookie
        gets 401 ("Session required for project reordering"), distinct from
        the general auth-failure 401. Any project id in the array that
        doesn't belong to the caller's company is silently skipped, not
        rejected.
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [projects]
              properties:
                projects:
                  type: array
                  items: { type: integer }
                  description: Project ids in the desired display order (array index becomes sort_order).
      responses:
        "200": { description: Saved, content: { application/json: { schema: { type: object, properties: { success: { type: boolean } } } } } }
        "401": { description: "No session user_id present", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ══════════════════════════════════════════════════════════════
  # TASKS — module gate: 'tasks'. quick_capture scope: create only.
  # ai_agent scope: list/get/create/update/complete/reopen (no delete/images/history).
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=tasks&action=list:
    get:
      operationId: tasksList
      summary: List tasks
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Tasks]
      parameters:
        - name: project_id
          in: query
          schema: { type: integer }
        - name: category
          in: query
          schema: { type: string }
        - name: include_done
          in: query
          schema: { type: boolean }
          description: Only relevant when category is set — statuses other than 'done' are excluded by default when filtering by category. Omitting category includes done tasks regardless of this flag.
      responses:
        "200":
          description: Array of tasks
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Task" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=tasks&action=get:
    get:
      operationId: tasksGet
      summary: Read a single task (with full comment history and images)
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Tasks]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200":
          description: The task
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=tasks&action=create:
    post:
      operationId: tasksCreate
      summary: Create a task
      description: >
        Scopes — `quick_capture` (create only), `ai_agent`, plus full-access
        key or session. Not idempotent — no dedupe-key support; a retried
        call creates a second task.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                project_id: { type: integer, description: "Defaults to 15 (general todo) if omitted." }
                title: { type: string }
                description: { type: string, nullable: true }
                tags: { type: string, description: "Comma-separated; also accepts an array." }
                status: { type: string, enum: [todo, in-progress, done, blocked], default: todo }
                priority: { type: string, enum: [low, normal, high, urgent], default: normal }
                category: { type: string, nullable: true }
                due_date: { type: string, format: date, nullable: true }
                delivery_mode:
                  type: string
                  enum: [commit_push, commit_only, no_commit]
                  default: commit_push
                  description: "Delivery policy for finished work; server-side validated. Omitted -> commit_push."
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  message: { type: string }
                  delivery_mode: { type: string, enum: [commit_push, commit_only, no_commit] }
                  url: { type: string }
        "400": { description: "Invalid delivery_mode", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { description: "project_id does not belong to the caller's company", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=tasks&action=update:
    post:
      operationId: tasksUpdate
      summary: Update a task
      description: >
        Scopes — `ai_agent`, plus full-access key or session. `version` is
        optional — the conflict check only runs when supplied (no existing
        browser caller sends one). Writes a field-level `task_changes` audit
        row for every field that actually changed.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                title: { type: string }
                description: { type: string, nullable: true }
                tags: { type: string }
                status: { type: string, enum: [todo, in-progress, done, blocked] }
                priority: { type: string, enum: [low, normal, high, urgent] }
                category: { type: string, nullable: true }
                due_date: { type: string, format: date, nullable: true }
                delivery_mode:
                  type: string
                  enum: [commit_push, commit_only, no_commit]
                  description: "Change the delivery policy; server-side validated. Omit to leave unchanged."
                project_id: { type: integer, description: "Reassign to a different project (must belong to the same company)." }
                version:
                  type: integer
                  description: >
                    If supplied, must match the task's current version or the
                    request fails with 409. Omit to update unconditionally.
      responses:
        "200":
          description: Updated task
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "400": { description: "Nothing to update, or invalid delivery_mode", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictTask" }

  /api.php?resource=tasks&action=complete:
    post:
      operationId: tasksComplete
      summary: Mark a task done (convenience wrapper over update)
      description: >
        Scopes — `ai_agent`, plus full-access key or session. Idempotent in
        effect (completing an already-done task is a no-op state-wise), but
        has no dedicated dedupe mechanism — this is a property of the
        underlying update, not a purpose-built idempotency key.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                version: { type: integer, description: "Optional optimistic-concurrency version; stale values return 409." }
      responses:
        "200":
          description: Task with status=done
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictTask" }

  /api.php?resource=tasks&action=reopen:
    post:
      operationId: tasksReopen
      summary: Reopen a task to status=todo
      description: Scopes — `ai_agent`, plus full-access key or session. Always sets status to 'todo' — the status that preceded completion is not tracked or restored.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                version: { type: integer, description: "Optional optimistic-concurrency version; stale values return 409." }
      responses:
        "200": { description: Task with status=todo, content: { application/json: { schema: { $ref: "#/components/schemas/Task" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictTask" }

  /api.php?resource=tasks&action=delete:
    post:
      operationId: tasksDelete
      summary: Delete a task (hard delete)
      description: >
        Scopes — full-access key or session only (not `ai_agent`).
        **Destructive and irreversible.** Hard-deletes the task's comments,
        then the task row. `activity_events`/`document_task_links` rows
        that referenced this task are left with a dangling `task_id` by
        design — see
        [hub-agent-api-capability-guide.md §9](hub-agent-api-capability-guide.md#9-activity-logging).
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=tasks&action=history:
    get:
      operationId: tasksHistory
      summary: Read a task's field-level change audit trail
      description: Scopes — full-access key or session only (not `ai_agent`). Written automatically by `tasks.update`/`complete`/`reopen` — one row per changed field per save.
      tags: [Tasks]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200":
          description: Array of task_changes rows, newest first
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer }
                    task_id: { type: integer }
                    changed_by: { type: integer, nullable: true }
                    field: { type: string }
                    old_value: { type: string, nullable: true }
                    new_value: { type: string, nullable: true }
                    changed_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=tasks&action=upload_image:
    post:
      operationId: tasksUploadImage
      summary: Attach an image to a task
      description: >
        Scopes — full-access key or session only (not `ai_agent`).
        Multipart form upload, not JSON. Max 5MB; MIME allow-list
        jpg/png/gif/webp only (verified via `finfo`, not the client-supplied
        Content-Type). Stored under `uploads/{task_id}/` — served directly
        by the web server, unlike document attachments.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [task_id, file]
              properties:
                task_id: { type: integer }
                file: { type: string, format: binary }
      responses:
        "201":
          description: Created image row
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Image" }
        "400": { description: "No file, upload error, file too large, or invalid MIME type", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=tasks&action=delete_image:
    post:
      operationId: tasksDeleteImage
      summary: Delete a task image
      description: Scopes — full-access key or session only (not `ai_agent`). Deletes the file from disk and the row. Irreversible.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [image_id], properties: { image_id: { type: integer } } }
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # COMMENTS (task comments) — module gate: 'tasks'. ai_agent: list/create only.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=comments&action=list:
    get:
      operationId: taskCommentsList
      summary: Read complete task comments in deterministic chronological order
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Tasks]
      parameters:
        - { name: task_id, in: query, required: true, schema: { type: integer } }
      responses:
        "200": { description: "Oldest first; ties ordered by comment ID", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/TaskComment" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=comments&action=create:
    post:
      operationId: taskCommentsCreate
      summary: Add an ordinary user-visible task comment
      description: >
        Scopes — `ai_agent`, plus full-access key or session. Not
        idempotent — no dedupe-key support; a retried call creates a second
        comment.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_id, author, body]
              properties:
                task_id: { type: integer }
                author: { type: string }
                body: { type: string }
      responses:
        "201": { description: Created comment, content: { application/json: { schema: { $ref: "#/components/schemas/TaskComment" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=comments&action=delete:
    post:
      operationId: taskCommentsDelete
      summary: Delete a task comment
      description: Scopes — full-access key or session only (not `ai_agent`). Hard delete, irreversible.
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # INTERACTIONS — RETIRED (Hub Task #10088, 2026-08-07). No paths here by
  # design: `resource=interactions` now returns 410 Gone for every action
  # (api.php no longer defines interactions_list()/interactions_create()
  # at all — removed, not merely unrouted). It had no cross-tenant
  # exposure at the entitlement layer (module gate: `tasks`, confirmed
  # correct, Hub Task #10087) but a structural one at the row level: the
  # backing table had no `company_id` column, and `list` with no
  # `project_id` returned every company's rows. Investigation found the
  # resource dormant since creation (2026-06-10 — 3 historical rows, all
  # company #1, no UI, no MCP tool, no test) and superseded in intent by
  # `activity_events` (its own `context_json` field already anticipated
  # holding the same provider/model/session metadata). Retired rather
  # than retrofitted — see
  # hub-agent-api-capability-guide.md §21 for the full architecture
  # comparison and rationale. The historical `interactions` database is
  # left in place, untouched; only the API surface is gone.
  #
  # `projects.get-with-context`'s own interactions sub-query (see the
  # `Project.interactions` field below) is unaffected — a separate,
  # already project-scoped read that was never part of the vulnerability.
  # ══════════════════════════════════════════════════════════════
  # IMAGES — module gate: 'tasks'. Only `list` is routed here; upload/
  # delete for task images live under the `tasks` resource (above).
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=images&action=list:
    get:
      operationId: imagesList
      summary: List images attached to a task
      description: Scopes — full-access key or session only (not `ai_agent`).
      tags: [Tasks]
      parameters:
        - { name: task_id, in: query, required: true, schema: { type: integer } }
      responses:
        "200": { description: Array of images, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Image" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # BRIEFINGS — NOT module-gated (companies/data_reset/search/briefings are
  # the deliberately-ungated set; briefings has no in-Hub page route today,
  # it's a cross_app HUB_MODULES entry). Not reachable by any scoped key.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=briefings&action=list:
    get:
      operationId: briefingsList
      summary: List recent briefings
      description: Scopes — full-access key or session only.
      tags: [Briefings]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 30 }
          description: Zero or negative values fall back to the default of 30 rather than erroring.
      responses:
        "200":
          description: Array of {id, briefing_date, created_at} — no content_markdown (use `get` for the full row)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties: { id: { type: integer }, briefing_date: { type: string, format: date }, created_at: { type: string, format: date-time } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api.php?resource=briefings&action=get:
    get:
      operationId: briefingsGet
      summary: Read one briefing by date
      description: Scopes — full-access key or session only.
      tags: [Briefings]
      parameters:
        - { name: date, in: query, required: true, schema: { type: string, format: date } }
      responses:
        "200": { description: The briefing, content: { application/json: { schema: { $ref: "#/components/schemas/Briefing" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=briefings&action=create:
    post:
      operationId: briefingsCreate
      summary: Create or replace the briefing for a date (upsert)
      description: >
        Scopes — full-access key or session only. **Upsert, not strictly a
        create**: `ON DUPLICATE KEY UPDATE` on the unique (company_id,
        briefing_date) key — calling this twice for the same date replaces
        the content rather than erroring or duplicating. This is the
        closest thing to idempotency this resource has, though it's a
        side effect of the upsert key, not a `client_dedupe_key` mechanism.
      tags: [Briefings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [briefing_date, content_markdown]
              properties:
                briefing_date: { type: string, format: date }
                content_markdown: { type: string }
      responses:
        "201": { description: Saved (created or replaced), content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ══════════════════════════════════════════════════════════════
  # DOCUMENT_TASK_LINKS — module gate: 'documents'. Not reachable by any
  # scoped key. "Save-then-link": create/delete both save the document
  # first via documents.update semantics before touching the link itself.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=document_task_links&action=list:
    get:
      operationId: documentTaskLinksList
      summary: List a document's active task links
      description: Scopes — full-access key or session only.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/DocumentIdQuery"
      responses:
        "200":
          description: Array of links; `resolved:false` and `task:null` when the linked task was hard-deleted
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DocumentTaskLink" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_task_links&action=create:
    post:
      operationId: documentTaskLinksCreate
      summary: Link a task to a document, saving the document's content in the same call
      description: >
        Scopes — full-access key or session only. Saves the document via
        the same optimistic-concurrency path as `documents.update` — a
        stale `version` 409s before any link is created. Rejects with 400
        if the pair is already actively linked.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_id, task_id, version, markdown_content]
              properties:
                document_id: { type: integer }
                task_id: { type: integer }
                version: { type: integer, description: "Must match the document's current version." }
                markdown_content: { type: string, description: "The document's full content, including the caller-spliced [[task:N]] marker." }
      responses:
        "201":
          description: Created link, updated document, and the linked task
          content:
            application/json:
              schema:
                type: object
                properties:
                  link_id: { type: integer }
                  document: { $ref: "#/components/schemas/Document" }
                  task: { $ref: "#/components/schemas/Task" }
        "400": { description: "Missing field, or already linked", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictDocument" }

  /api.php?resource=document_task_links&action=delete:
    post:
      operationId: documentTaskLinksDelete
      summary: Unlink a task from a document, saving the document's content in the same call
      description: >
        Scopes — full-access key or session only. Soft-unlink only — the
        link row is never deleted, only marked `unlinked_at`/`unlinked_by`,
        preserving a permanent audit trail. Same save-then-mutate ordering
        as `create`.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_id, task_id, version, markdown_content]
              properties:
                document_id: { type: integer }
                task_id: { type: integer }
                version: { type: integer }
                markdown_content: { type: string }
      responses:
        "200":
          description: Updated document, and whether an active link was actually found and unlinked
          content:
            application/json:
              schema:
                type: object
                properties:
                  document: { $ref: "#/components/schemas/Document" }
                  unlinked: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictDocument" }

  /api.php?resource=document_task_links&action=add_note:
    post:
      operationId: documentTaskLinksAddNote
      summary: Add a Journal-sourced note to a task as a provenance-tracked comment
      description: >
        Scopes — full-access key or session only. Synthesizes a
        `Journal:`/`Section:`/`Imported:`/`Source:` header and inserts an
        ordinary task comment (author `"Hub Journal"`), plus a
        `comment_provenance` row. Idempotent within roughly the same
        minute: an identical note (ignoring the `Imported:` timestamp line)
        already on the task returns the existing comment with
        `duplicate:true` instead of inserting a second one — not a
        `client_dedupe_key` mechanism, a content-based dedupe check.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_id, document_id, body]
              properties:
                task_id: { type: integer }
                document_id: { type: integer }
                body: { type: string }
                section_heading: { type: string, nullable: true, description: "Defaults to 'Untitled section' if omitted." }
                section_heading_slug: { type: string, nullable: true }
      responses:
        "201":
          description: Created (or matched an existing duplicate)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  duplicate: { type: boolean }
                  message: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # OUTREACH_LOG — module gate: 'documents'. Not reachable by any scoped
  # key. Job Outreach Journal Automation (Hub Task #7641). Same
  # save-then-record ordering as document_task_links.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=outreach_log&action=list:
    get:
      operationId: outreachLogList
      summary: List logged outreach events
      description: Scopes — full-access key or session only.
      tags: [Documents]
      parameters:
        - { name: document_id, in: query, schema: { type: integer }, description: "Optional — filters to outreach logged from one Journal document." }
      responses:
        "200": { description: "Array of outreach entries, newest first", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/OutreachLogEntry" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=outreach_log&action=create:
    post:
      operationId: outreachLogCreate
      summary: Record an outreach event, saving the source document's content in the same call
      description: >
        Scopes — full-access key or session only. Same save-then-record
        ordering as `document_task_links.create` — the document is saved
        via `documents.update`'s optimistic-concurrency path first; nothing
        is recorded if that fails.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_id, version, markdown_content, outreach_type, company_name]
              properties:
                document_id: { type: integer }
                version: { type: integer }
                markdown_content: { type: string }
                outreach_type: { type: string, enum: [cold_outreach, application, follow_up, reconnection], description: "App-validated (OUTREACH_TYPES), not a DB ENUM." }
                company_name: { type: string }
                task_id: { type: integer, nullable: true }
                contact_name: { type: string, nullable: true }
                subject: { type: string, nullable: true }
                shared_document_ids: { type: string, nullable: true, description: "Comma-separated document ids (also accepts an array); each must belong to the caller's company." }
                notes: { type: string, nullable: true }
                planned_followup_date: { type: string, format: date, nullable: true }
                occurred_at: { type: string, format: date-time, nullable: true, description: "Defaults to the current server time if omitted — this field is NOT server-authoritative the way activity_events.occurred_at is; a caller may backdate it." }
      responses:
        "201":
          description: Created outreach entry plus the updated document
          content:
            application/json:
              schema:
                allOf:
                  - { type: object, properties: { id: { type: integer }, document: { $ref: "#/components/schemas/Document" } } }
                  - { $ref: "#/components/schemas/OutreachLogEntry" }
        "400": { description: "Missing field, or invalid outreach_type", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictDocument" }

  # ══════════════════════════════════════════════════════════════
  # ACTIVITY_EVENTS — module gate: 'tasks' (baseline/always-on). ai_agent
  # scope: create/list/get — full access, the primary intended writer.
  # The ONLY resource in this API with real idempotency
  # (client_dedupe_key). See hub-agent-api-capability-guide.md §9.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=activity_events&action=create:
    post:
      operationId: activityEventsCreate
      summary: Record one activity event (idempotent via client_dedupe_key)
      description: >
        Scopes — `ai_agent` (full access — this is its primary intended
        writer), plus full-access key or session. `occurred_at` is always
        server-set at insert time and is never client-overridable —
        `client_occurred_at` separately stores whatever the caller reports,
        for comparison, never as the authoritative timestamp.


        **Idempotency**: pass `client_dedupe_key` and a retried call with
        the same key (for this company) returns the original row with
        `duplicate:true` instead of inserting a second one — enforced by a
        UNIQUE `(company_id, client_dedupe_key)` constraint, not just an
        application-layer pre-check, so it's safe under concurrent retries.
        Omitting it means a retry creates a second, indistinguishable row —
        same as every other create action in this API.
      tags: [ActivityEvents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_type, summary]
              properties:
                event_type:
                  type: string
                  enum: [work_started, progress, decision, task_created, task_completed, bug_found, commit_pushed, meeting, note]
                  description: App-validated (ACTIVITY_EVENT_TYPES), not a DB ENUM.
                summary: { type: string }
                project_id: { type: integer, nullable: true }
                task_id: { type: integer, nullable: true, description: "No FK — tasks hard-delete, so a dangling task_id is the intended steady state after the referenced task is deleted." }
                document_id: { type: integer, nullable: true }
                decisions: { type: string, nullable: true }
                files_affected: { type: string, nullable: true }
                next_step: { type: string, nullable: true }
                session_title: { type: string, nullable: true }
                performed_by_type:
                  type: string
                  enum: [user, agent, system, integration]
                  default: user
                  description: App-validated (ACTIVITY_PERFORMER_TYPES), not a DB ENUM.
                performed_by: { type: string, nullable: true, description: "Free-text actor label, e.g. \"Claude Code\", \"Bill Hagara\"." }
                source_reference: { type: string, nullable: true, description: "Generic cross-reference id — commit SHA, conversation id, etc." }
                context_json: { type: object, nullable: true, description: "Forward-looking, unstructured metadata slot. No schema enforced." }
                client_occurred_at: { type: string, format: date-time, nullable: true, description: "What the caller reports happened — never authoritative." }
                client_dedupe_key: { type: string, nullable: true, description: "See idempotency note above." }
      responses:
        "201":
          description: Created (or the original row, if client_dedupe_key matched an existing event)
          content:
            application/json:
              schema:
                allOf:
                  - { type: object, properties: { duplicate: { type: boolean } } }
                  - { $ref: "#/components/schemas/ActivityEvent" }
        "400": { description: "Missing summary, or invalid event_type/performed_by_type/context_json", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { description: "project_id/task_id/document_id given but not found", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=activity_events&action=list:
    get:
      operationId: activityEventsList
      summary: List activity events, filterable and paginated
      description: >
        Scopes — `ai_agent`, plus full-access key or session. Append-only
        store — there is no update or delete action for this resource.
      tags: [ActivityEvents]
      parameters:
        - { name: project_id, in: query, schema: { type: integer } }
        - { name: task_id, in: query, schema: { type: integer } }
        - { name: document_id, in: query, schema: { type: integer } }
        - { name: event_type, in: query, schema: { type: string, enum: [work_started, progress, decision, task_created, task_completed, bug_found, commit_pushed, meeting, note] } }
        - { name: performed_by, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: string, format: date-time }, description: "occurred_at >= this server-time value." }
        - { name: until, in: query, schema: { type: string, format: date-time }, description: "occurred_at <= this server-time value." }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 }, description: "Clamped to [1, 500], not rejected." }
      responses:
        "200":
          description: Array of events, ordered occurred_at DESC, id DESC
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ActivityEvent" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=activity_events&action=get:
    get:
      operationId: activityEventsGet
      summary: Read a single activity event
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [ActivityEvents]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200": { description: The event, content: { application/json: { schema: { $ref: "#/components/schemas/ActivityEvent" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # SEARCH — NOT module-gated at the router (self-gated per result type
  # internally). ai_agent scope: search. Currently searches tasks only.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=search&action=search:
    get:
      operationId: search
      summary: General search across Hub resources (currently covers tasks only)
      tags: [Search]
      description: >
        Scopes — `ai_agent` (the intended canonical search mechanism for AI
        agents), plus full-access key or session. Currently searches
        **tasks only** (title/description, case-insensitive, partial
        match) despite the `type`/pagination contract being designed to
        add more resource types later without changing shape. Each result
        type enforces its own module-entitlement gate internally (returns
        no results for a disabled module rather than erroring the whole
        request) — this resource has no single module gate of its own.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: Search text. Matched against task title and description.
        - name: type
          in: query
          schema: { type: string }
          description: Comma-separated resource types. Omit for all supported types. Only 'task' is implemented; any other value is a 400.
        - name: project_id
          in: query
          schema: { type: integer }
          description: Limit task results to a project. A project_id outside the caller's company yields zero results, not an error.
        - name: status
          in: query
          schema: { type: string, enum: [todo, in-progress, done, blocked] }
        - name: priority
          in: query
          schema: { type: string, enum: [low, normal, high, urgent] }
        - name: include_completed
          in: query
          schema: { type: boolean, default: false }
          description: Include status=done tasks. Default false.
        - name: limit
          in: query
          schema: { type: integer, default: 25, maximum: 100 }
          description: Values above 100 are clamped to 100, not rejected. Zero, negative, or non-numeric values are a 400.
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
          description: Negative or non-numeric values are a 400.
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }
        "400":
          description: Missing/blank q, unsupported type, or invalid limit/offset
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ══════════════════════════════════════════════════════════════
  # COMPANIES — self-service nav settings ("App Settings"). NOT
  # module-gated (meta/admin surface with its own authorization shape).
  # Never touches companies.enabled_apps (entitlement) or companies.status
  # (suspension) — both are admin.php-only, not reachable via api.php at
  # all. Not reachable by any scoped key.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=companies&action=company_settings_get:
    get:
      operationId: companySettingsGet
      summary: Read the caller's own company nav settings and entitled modules
      description: >
        Scopes — full-access key or session only. A pure API-key caller
        with no session cookie has no `user_id` — falls back to "show
        everything entitled" rather than erroring.
      tags: [Companies]
      responses:
        "200": { description: Settings, content: { application/json: { schema: { $ref: "#/components/schemas/CompanySettings" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "company not found (should not occur for an authenticated caller)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=companies&action=company_settings_update:
    post:
      operationId: companySettingsUpdate
      summary: Update the caller's own default landing app and visible-module preference
      description: >
        Scopes — full-access key or session only. **Self-service only** —
        writes exclusively to `companies.default_app` and the calling
        user's own `users.nav_preferences`; never touches
        `companies.enabled_apps` (entitlement) or `companies.status`
        (suspension), both of which are admin.php-only and not reachable
        through any api.php action. `visible_modules` outside the
        company's entitled set is silently dropped, never an error.
      tags: [Companies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                default_app: { type: string, description: "Must be one of the company's entitled cross-apps unless left unchanged." }
                visible_modules: { type: array, items: { type: string }, description: "Omitted = show everything entitled." }
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  default_app: { type: string }
                  visible_modules: { type: array, items: { type: string } }
        "400": { description: "invalid default_app, or visible_modules not an array", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ══════════════════════════════════════════════════════════════
  # DATA_RESET — admin-only (own gate, not module-gated). Full-access key
  # trusted as-is; session caller must have is_admin=1. Never reachable by
  # any scoped API key (rejected by the pre-router scope gate before
  # dispatch). Only ISCD (company_id=1) may target another company.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=data_reset&action=list_options:
    get:
      operationId: dataResetListOptions
      summary: Read the manifest of apps/modules/tables eligible for backup or reset
      description: Admin-only — full-access key (trusted as-is) or a session with is_admin=1. Not reachable by any scoped key.
      tags: [DataReset]
      responses:
        "200": { description: Manifest, content: { application/json: { schema: { type: object, properties: { success: { type: boolean }, manifest: { type: object } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "Session caller is not is_admin=1", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=data_reset&action=execute:
    post:
      operationId: dataResetExecute
      summary: Back up (and optionally delete) one company's data for selected tables
      description: >
        Admin-only — full-access key or is_admin=1 session. **Destructive
        in `reset` mode.** Every table is `mysqldump`-backed-up (scoped to
        the target company via `--where`) BEFORE any delete; a table only
        deletes if its own dump succeeded. `reset` mode never uses
        TRUNCATE — always a transactional `DELETE ... WHERE company_id = ?`.
        Every table name is validated against a fixed manifest — an
        arbitrary table name is always rejected, never passed through to
        SQL. Only ISCD (company_id=1) may target `company_id` values other
        than its own caller's company; every other caller is locked to its
        own company_id regardless of what's supplied. Resetting company_id=1
        itself additionally requires `confirm_iscd: true`.
      tags: [DataReset]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [app, module, tables, mode, company_id]
              properties:
                app: { type: string, description: "Must exist in the manifest." }
                module: { type: string, description: "Must exist under app in the manifest." }
                tables: { type: array, items: { type: string }, description: "Must all belong to module in the manifest." }
                mode: { type: string, enum: [backup, reset] }
                company_id: { type: integer, description: "Non-ISCD callers may only pass their own company_id." }
                confirm_iscd: { type: boolean, description: "Required (true) when mode=reset and company_id=1." }
      responses:
        "200":
          description: Per-table results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        table: { type: string }
                        rows_backed_up: { type: integer }
                        rows_deleted: { type: integer, description: "0 in backup mode." }
                        backup_file: { type: string }
        "400": { description: "Invalid app/module/table/mode, or missing confirm_iscd for an ISCD reset", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "Not admin, or targeting another company as a non-ISCD caller", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: "Backup or delete failed — see the error message", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=data_reset&action=history:
    get:
      operationId: dataResetHistory
      summary: Read the paginated backup/reset audit log
      description: Admin-only — full-access key or is_admin=1 session. Non-ISCD callers are always scoped to their own company_id; ISCD may pass an explicit company_id filter or omit it to see all companies.
      tags: [DataReset]
      parameters:
        - { name: company_id, in: query, schema: { type: integer }, description: "ISCD callers only — ignored (forced to the caller's own company_id) for everyone else." }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, default: 25, maximum: 200 } }
      responses:
        "200":
          description: Paginated history
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  total: { type: integer }
                  page: { type: integer }
                  per_page: { type: integer }
                  rows: { type: array, items: { $ref: "#/components/schemas/DataResetLogEntry" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ══════════════════════════════════════════════════════════════
  # FOLDERS (document folders) — module gate: 'documents'. Not reachable
  # by any scoped key.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=folders&action=list:
    get:
      operationId: foldersList
      summary: List document folders
      description: Scopes — full-access key or session only.
      tags: [Documents]
      responses:
        "200": { description: "Array of folders — root folders first, then sort_order/name", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/DocumentFolder" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=folders&action=create:
    post:
      operationId: foldersCreate
      summary: Create a document folder
      description: Scopes — full-access key or session only.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                parent_id: { type: integer, nullable: true }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentFolder" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { description: "parent_id given but not found", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=folders&action=update:
    post:
      operationId: foldersUpdate
      summary: Rename a folder or change its sort order
      description: Scopes — full-access key or session only.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                name: { type: string }
                sort_order: { type: integer }
      responses:
        "200": { description: Updated folder, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentFolder" } } } }
        "400": { description: "Nothing to update, or blank name", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=folders&action=move:
    post:
      operationId: foldersMove
      summary: Move a folder under a different parent (or to root)
      description: >
        Scopes — full-access key or session only. Rejects self-parenting
        and, via a recursive CTE cycle check, moving a folder into any of
        its own descendants.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                parent_id: { type: integer, nullable: true, description: "Null moves the folder to root." }
      responses:
        "200": { description: Updated folder, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentFolder" } } } }
        "400": { description: "Self-parenting, or moving into own descendant", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=folders&action=delete:
    post:
      operationId: foldersDelete
      summary: Delete an empty document folder (soft delete)
      description: Scopes — full-access key or session only. Refuses with 409 if the folder has any non-deleted child folders or documents.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { id: { type: integer }, message: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Folder is not empty", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ══════════════════════════════════════════════════════════════
  # DOCUMENTS — module gate: 'documents'. ai_agent scope: search/get/
  # create/update (no delete, no move).
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=documents&action=list:
    get:
      operationId: documentsList
      summary: List documents
      description: Scopes — `ai_agent` reaches this resource but NOT the `list` action (only search/get/create/update — see the ai_agent allow-list); full-access key or session only for `list`.
      tags: [Documents]
      parameters:
        - { name: folder_id, in: query, schema: { type: string }, description: "Integer id, or empty string for root-level documents. Omit for all folders." }
        - { name: project_id, in: query, schema: { type: integer } }
        - { name: sort, in: query, schema: { type: string, enum: [title_asc, title_desc, updated_desc, updated_asc], default: title_asc } }
      responses:
        "200":
          description: Array of documents (list-shape — no markdown_content; includes share_view_count/share_last_viewed_at aggregates)
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/DocumentListItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=documents&action=get:
    get:
      operationId: documentsGet
      summary: Read a single document, full content
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200": { description: The document, content: { application/json: { schema: { $ref: "#/components/schemas/Document" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=documents&action=create:
    post:
      operationId: documentsCreate
      summary: Create a document
      description: >
        Scopes — `ai_agent`, plus full-access key or session. Not
        idempotent — no dedupe-key support. Every successful create also
        writes the document's first `document_revisions` snapshot,
        atomically, in the same transaction.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                markdown_content: { type: string }
                summary: { type: string, nullable: true }
                folder_id: { type: integer, nullable: true }
                project_id: { type: integer, nullable: true }
                document_type:
                  type: string
                  enum: [document, journal]
                  default: document
                  description: >
                    App-validated (DOCUMENT_TYPES), not a DB ENUM. `journal`
                    is the Daily Journal classification (Hub Task #9852) —
                    storage/classification only, no rendering; see
                    [hub-agent-api-capability-guide.md §10](hub-agent-api-capability-guide.md#10-daily-journal-support).
                journal_date:
                  type: string
                  format: date
                  nullable: true
                  description: >
                    Only meaningful when document_type=journal — forced to
                    NULL server-side for any other type, so the two fields
                    can never drift out of sync. No enforced one-journal-
                    per-day uniqueness; callers must check-then-create.
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Document" } } } }
        "400": { description: "Missing title, or invalid document_type", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { description: "folder_id or project_id given but not found", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=documents&action=update:
    post:
      operationId: documentsUpdate
      summary: Update a document
      description: >
        Scopes — `ai_agent`, plus full-access key or session. `version` is
        **required** (400 if omitted — unlike `tasks.update`, where it's
        optional). Every successful save writes a new `document_revisions`
        snapshot — not optional.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, version]
              properties:
                id: { type: integer }
                version: { type: integer }
                title: { type: string }
                markdown_content: { type: string }
                summary: { type: string, nullable: true, description: "Empty string clears it to NULL." }
                document_type: { type: string, enum: [document, journal] }
                journal_date: { type: string, format: date, nullable: true }
      responses:
        "200": { description: Updated document, content: { application/json: { schema: { $ref: "#/components/schemas/Document" } } } }
        "400": { description: "Missing version, or blank title", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictDocument" }

  /api.php?resource=documents&action=move:
    post:
      operationId: documentsMove
      summary: Reassign a document's folder and/or project
      description: Scopes — full-access key or session only (NOT `ai_agent`). Structural only — no revision written, no version bump.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                folder_id: { type: integer, nullable: true }
                project_id: { type: integer, nullable: true }
      responses:
        "200": { description: Updated document, content: { application/json: { schema: { $ref: "#/components/schemas/Document" } } } }
        "400": { description: "Neither folder_id nor project_id supplied", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=documents&action=delete:
    post:
      operationId: documentsDelete
      summary: Delete a document (soft delete)
      description: Scopes — full-access key or session only (NOT `ai_agent`). Soft delete (`deleted_at`) — recoverable at the database level, though no `restore` action is exposed through this API.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { id: { type: integer }, message: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=documents&action=search:
    get:
      operationId: documentsSearch
      summary: Search documents by title/summary, optionally full content, plus attachment filenames
      description: >
        Scopes — `ai_agent`, plus full-access key or session. Title/summary
        always matched via `LIKE`; content matched via MySQL FULLTEXT only
        when `include_content=true`. Known limitation: MySQL's
        `innodb_ft_min_token_size` (3 on this installation) silently drops
        shorter query tokens from FULLTEXT matching — not a bug in this
        endpoint, a MySQL FULLTEXT index characteristic.
      tags: [Documents]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
        - { name: include_content, in: query, schema: { type: boolean, default: false } }
        - { name: sort, in: query, schema: { type: string, enum: [title_asc, title_desc, updated_desc, updated_asc], default: title_asc } }
      responses:
        "200":
          description: Documents (max 50) plus up to 20 attachment filename matches
          content:
            application/json:
              schema:
                type: object
                properties:
                  documents: { type: array, items: { $ref: "#/components/schemas/DocumentListItem" } }
                  attachments:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        document_id: { type: integer }
                        original_name: { type: string }
                        document_title: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  # ══════════════════════════════════════════════════════════════
  # DOCUMENT_REVISIONS — module gate: 'documents'. Not reachable by any
  # scoped key. Forward-only restore (never rewrites history).
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=document_revisions&action=list:
    get:
      operationId: documentRevisionsList
      summary: List a document's revision history (metadata only)
      description: Scopes — full-access key or session only.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/DocumentIdQuery"
      responses:
        "200": { description: "Array, newest revision first — no markdown_snapshot (use get for that)", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/DocumentRevisionSummary" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_revisions&action=get:
    get:
      operationId: documentRevisionsGet
      summary: Read one revision, full content
      description: Scopes — full-access key or session only.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200": { description: The revision including markdown_snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentRevisionDetail" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_revisions&action=restore:
    post:
      operationId: documentRevisionsRestore
      summary: Restore a document to a prior revision's content
      description: >
        Scopes — full-access key or session only. **Forward-only**: writes
        a brand-new revision snapshotting the CURRENT (pre-restore) content
        before applying the old content, then bumps the live document's
        version. History is never rewritten or deleted.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer, description: "The document_revisions row id to restore, not the document id." } } }
      responses:
        "200": { description: "The live document, now matching the restored revision's content", content: { application/json: { schema: { $ref: "#/components/schemas/Document" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # DOCUMENT_ATTACHMENTS — module gate: 'documents'. Not reachable by any
  # scoped key (ai_agent explicitly excluded from upload).
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=document_attachments&action=list:
    get:
      operationId: documentAttachmentsList
      summary: List a document's attachments (metadata only)
      description: Scopes — full-access key or session only.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/DocumentIdQuery"
      responses:
        "200": { description: Array of attachments, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/DocumentAttachment" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_attachments&action=upload:
    post:
      operationId: documentAttachmentsUpload
      summary: Upload a file attachment to a document
      description: >
        Scopes — full-access key or session only. Multipart form upload.
        Max 25MB; MIME allow-list (pdf/doc/docx/xls/xlsx/csv/txt/jpg/png/
        gif/webp, verified via `finfo`). Stored **outside the web root**
        (`/var/lib/hub/document_attachments/{company_id}/{document_id}/`,
        mode 0640) — never directly served by the web server; every read
        goes through the authenticated `download` action below.
      tags: [Documents]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [document_id, file]
              properties:
                document_id: { type: integer }
                file: { type: string, format: binary }
      responses:
        "201": { description: Created attachment metadata, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentAttachment" } } } }
        "400": { description: "No file, too large, or invalid MIME type", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_attachments&action=download:
    get:
      operationId: documentAttachmentsDownload
      summary: Download an attachment's raw file bytes
      description: >
        Scopes — full-access key or session only. Streams the file with
        `Content-Type`/`Content-Disposition`/`Content-Length` headers,
        bypassing the JSON envelope entirely — this is the one action in
        this API whose 200 response body is not JSON.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200":
          description: Raw file bytes
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { description: "Not found, or the file is missing on disk", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api.php?resource=document_attachments&action=update:
    post:
      operationId: documentAttachmentsUpdate
      summary: Rename an attachment's display name (does not touch the stored file)
      description: >
        Scopes — full-access key or session only. Never renames the actual
        stored file or changes the attachment's identity — existing
        `![[attachment:id|...]]` inline references keep resolving
        unchanged. Blank string clears the display name back to NULL
        (falls back to the original filename at read time).
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, display_name]
              properties:
                id: { type: integer }
                display_name: { type: string, nullable: true, description: "Max 255 characters. Empty string clears to NULL." }
      responses:
        "200": { description: Updated attachment, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentAttachment" } } } }
        "400": { description: "Nothing to update, or display_name too long", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_attachments&action=delete:
    post:
      operationId: documentAttachmentsDelete
      summary: Delete an attachment (soft delete — file stays on disk)
      description: Scopes — full-access key or session only. Soft delete only; the underlying file is retained on disk for recoverability.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Deleted, content: { application/json: { schema: { type: object, properties: { id: { type: integer }, message: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # DOCUMENT_SHARES — authenticated management of public share links.
  # Module gate: 'documents'. Not reachable by any scoped key. The
  # unauthenticated public-viewing side (share.php et al.) is NOT part of
  # this spec — see the top-level description.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=document_shares&action=create:
    post:
      operationId: documentSharesCreate
      summary: Mint a new public share link for a document
      description: >
        Scopes — full-access key or session only. Mints a 256-bit CSPRNG
        token; only its SHA-256 hash is ever persisted. **The raw token is
        returned exactly once, in this response** — it cannot be read back
        later (`list` never includes it). Not idempotent by nature: each
        call always mints a genuinely new token.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_id]
              properties:
                document_id: { type: integer }
                expires_at: { type: string, format: date-time, nullable: true }
                recipient_label: { type: string, nullable: true, description: "Private note for the owner, max 120 chars. Never embedded in the token/URL." }
                share_title: { type: string, nullable: true, description: "Public title shown on the share page instead of the document's own title. Max 255 chars." }
                passcode: { type: string, nullable: true, description: "If set, a visitor must enter this to view the share." }
      responses:
        "201": { description: "Created share, including the one-time raw token", content: { application/json: { schema: { $ref: "#/components/schemas/DocumentShareWithToken" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_shares&action=list:
    get:
      operationId: documentSharesList
      summary: List a document's shares, with engagement analytics
      description: Scopes — full-access key or session only. `token_hash`/`passcode_hash` are never included in the response.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/DocumentIdQuery"
      responses:
        "200": { description: Array of shares, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/DocumentShare" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_shares&action=update:
    post:
      operationId: documentSharesUpdate
      summary: Update an active share's expiry, passcode, label, or title
      description: Scopes — full-access key or session only. Rejects with 400 on an already-revoked share — no reactivation through any action.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                expires_at: { type: string, format: date-time, nullable: true }
                passcode: { type: string, nullable: true, description: "Null or empty clears the passcode requirement." }
                recipient_label: { type: string, nullable: true }
                share_title: { type: string, nullable: true }
      responses:
        "200": { description: Updated share, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentShare" } } } }
        "400": { description: "Nothing to update, or the share is revoked", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_shares&action=revoke:
    post:
      operationId: documentSharesRevoke
      summary: Revoke a share, immediately invalidating its token
      description: Scopes — full-access key or session only. Irreversible — no reactivation. View history and lifetime access_count are preserved, untouched.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Revoked share, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentShare" } } } }
        "400": { description: "Already revoked", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_shares&action=regenerate:
    post:
      operationId: documentSharesRegenerate
      summary: Revoke a share and mint a replacement with a fresh token
      description: >
        Scopes — full-access key or session only. Revokes the old share (if
        not already revoked) and creates an entirely new one, copying
        document_id/expires_at/passcode by default unless overridden. The
        old token is never reactivated. The new token is returned once,
        same as `create`.
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                expires_at: { type: string, format: date-time, nullable: true, description: "Overrides the old share's value if supplied." }
                passcode: { type: string, nullable: true, description: "Old passcode hashes can't be carried over — supply a new one explicitly, or the replacement has no passcode." }
      responses:
        "201": { description: "New share, including its one-time raw token", content: { application/json: { schema: { $ref: "#/components/schemas/DocumentShareWithToken" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_shares&action=reset_views:
    post:
      operationId: documentSharesResetViews
      summary: Reset the "views since" display baseline (non-destructive)
      description: >
        Scopes — full-access key or session only. Sets `reset_at` only —
        never deletes `document_share_views` rows, never touches the
        lifetime `access_count`. Allowed even on a revoked share (a display
        baseline, not a reactivation).
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: Updated share, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentShare" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=document_shares&action=preview:
    get:
      operationId: documentSharesPreview
      summary: "\"Test Link\" — authenticated preview of what the public page would show"
      description: >
        Scopes — full-access key or session only. Never resolves a public
        token and never reaches the view-recording path — this call is
        never counted as a view, regardless of the share's status.
      tags: [Documents]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200":
          description: Title (share_title, falling back to the document's own title) and content
          content:
            application/json:
              schema:
                type: object
                properties:
                  title: { type: string }
                  markdown_content: { type: string }
                  document_id: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # DOCUMENT_SHARE_VIEWS — per-share view audit log. Module gate:
  # 'documents'. Not reachable by any scoped key.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=document_share_views&action=list:
    get:
      operationId: documentShareViewsList
      summary: List per-view activity detail for one share
      description: Scopes — full-access key or session only. Read-only audit detail — no write actions exist for this resource.
      tags: [Documents]
      parameters:
        - { name: share_id, in: query, required: true, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200": { description: "Array, newest first", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/DocumentShareView" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # SONGS — module gate: 'songs'. Not reachable by any scoped key.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=songs&action=list:
    get:
      operationId: songsList
      summary: List songs
      description: Scopes — full-access key or session only.
      tags: [Songs]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [idea, in_progress, lyrics_complete] } }
        - { name: archived, in: query, schema: { type: boolean, default: false } }
      responses:
        "200": { description: "Array of songs (includes a 200-char lyrics_preview, not full lyrics)", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/SongListItem" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=songs&action=get:
    get:
      operationId: songsGet
      summary: Read a single song, full lyrics
      description: Scopes — full-access key or session only.
      tags: [Songs]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200": { description: The song, content: { application/json: { schema: { $ref: "#/components/schemas/Song" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=songs&action=create:
    post:
      operationId: songsCreate
      summary: Create a song
      description: >
        Scopes — full-access key or session only. Requires a non-blank
        title OR lyrics (not both). Creates the song, its single
        `song_sections` row, and its first `song_revisions` snapshot
        atomically.
      tags: [Songs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                lyrics: { type: string }
                status: { type: string, enum: [idea, in_progress, lyrics_complete], default: idea, description: "App-validated (SONGS_ALLOWED_STATUSES), not a DB ENUM." }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Song" } } } }
        "400": { description: "Both title and lyrics blank, or invalid status", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=songs&action=update:
    post:
      operationId: songsUpdate
      summary: Update a song's title, status, or lyrics
      description: >
        Scopes — full-access key or session only. `version` is
        **required** (400 if omitted). A no-op update (nothing actually
        different from the current row) commits without writing a new
        revision — meaningful-change detection, not a dedupe key.
      tags: [Songs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, version]
              properties:
                id: { type: integer }
                version: { type: integer }
                title: { type: string }
                status: { type: string, enum: [idea, in_progress, lyrics_complete] }
                lyrics: { type: string }
      responses:
        "200": { description: Updated song, content: { application/json: { schema: { $ref: "#/components/schemas/Song" } } } }
        "400": { description: "Missing version, or invalid status", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/VersionConflictSong" }

  /api.php?resource=songs&action=archive:
    post:
      operationId: songsArchive
      summary: Archive a song
      description: Scopes — full-access key or session only. Idempotent by construction — a WHERE-clause no-op if already archived, never an error.
      tags: [Songs]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: "The song, now archived", content: { application/json: { schema: { $ref: "#/components/schemas/Song" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=songs&action=unarchive:
    post:
      operationId: songsUnarchive
      summary: Restore an archived song
      description: Scopes — full-access key or session only. Idempotent by construction — a WHERE-clause no-op if not currently archived.
      tags: [Songs]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer } } }
      responses:
        "200": { description: "The song, now active", content: { application/json: { schema: { $ref: "#/components/schemas/Song" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=songs&action=search:
    get:
      operationId: songsSearch
      summary: Search songs by title or lyrics
      description: Scopes — full-access key or session only. Non-archived songs only, max 50 results.
      tags: [Songs]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Array of matching songs, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/SongListItem" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  # ══════════════════════════════════════════════════════════════
  # SONG_REVISIONS — module gate: 'songs'. Not reachable by any scoped
  # key. Forward-only restore.
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=song_revisions&action=list:
    get:
      operationId: songRevisionsList
      summary: List a song's revision history (with lyrics preview)
      description: Scopes — full-access key or session only.
      tags: [Songs]
      parameters:
        - { name: song_id, in: query, required: true, schema: { type: integer } }
      responses:
        "200": { description: "Array, newest revision first", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/SongRevisionSummary" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=song_revisions&action=get:
    get:
      operationId: songRevisionsGet
      summary: Read one song revision, full sections and lyrics
      description: Scopes — full-access key or session only.
      tags: [Songs]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200": { description: The revision, content: { application/json: { schema: { $ref: "#/components/schemas/SongRevisionDetail" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=song_revisions&action=restore:
    post:
      operationId: songRevisionsRestore
      summary: Restore a song to a prior revision's content
      description: >
        Scopes — full-access key or session only. **Forward-only**: writes
        a new revision (annotated "Restored from revision N") before
        applying the old content to the live song. History is never
        rewritten.
      tags: [Songs]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [id], properties: { id: { type: integer, description: "The song_revisions row id to restore, not the song id." } } }
      responses:
        "200": { description: "The live song, now matching the restored revision", content: { application/json: { schema: { $ref: "#/components/schemas/Song" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ══════════════════════════════════════════════════════════════
  # IDEAS — module gate: 'ideas'. ai_agent scope: list/get/create/update/
  # promote (full CRUD — there is no delete action at all).
  # ══════════════════════════════════════════════════════════════
  /api.php?resource=ideas&action=list:
    get:
      operationId: ideasList
      summary: List ideas
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Ideas]
      responses:
        "200": { description: Array of ideas, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Idea" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=ideas&action=get:
    get:
      operationId: ideasGet
      summary: Read a single idea
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Ideas]
      parameters:
        - $ref: "#/components/parameters/IdQuery"
      responses:
        "200": { description: The idea, content: { application/json: { schema: { $ref: "#/components/schemas/Idea" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=ideas&action=create:
    post:
      operationId: ideasCreate
      summary: Create an idea
      description: Scopes — `ai_agent`, plus full-access key or session.
      tags: [Ideas]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                description: { type: string, nullable: true }
                status: { type: string, enum: [idea, reviewing, ready], default: idea, description: "'suggested' is reserved for a future AI-suggestion workflow and is not settable through this action." }
                created_by: { type: string, nullable: true }
      responses:
        "201":
          description: Created idea
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Idea" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }

  /api.php?resource=ideas&action=update:
    post:
      operationId: ideasUpdate
      summary: Update an idea
      description: Scopes — `ai_agent`, plus full-access key or session. No `version`/optimistic-concurrency field exists on ideas.
      tags: [Ideas]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer }
                title: { type: string }
                description: { type: string, nullable: true }
                status: { type: string, enum: [idea, reviewing, ready] }
      responses:
        "200":
          description: Updated idea
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Idea" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api.php?resource=ideas&action=promote:
    post:
      operationId: ideasPromote
      summary: Promote an idea into a task
      tags: [Ideas]
      description: >
        Scopes — `ai_agent`, plus full-access key or session. Creates a
        task from the idea's title/description under the given project,
        sets the idea's status to 'ready' and links `promoted_task_id`.
        The idea is not deleted or hidden. An idea may only be promoted
        once (fails with 400 if already promoted).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, project_id]
              properties:
                id: { type: integer }
                project_id: { type: integer }
      responses:
        "201":
          description: The updated idea and the newly-created task
          content:
            application/json:
              schema:
                type: object
                properties:
                  idea: { $ref: "#/components/schemas/Idea" }
                  task: { $ref: "#/components/schemas/Task" }
        "400":
          description: Missing project_id, or idea already promoted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ModuleNotEnabled" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: >
        Three tiers, resolved by `includes/api_auth.php::api_authenticate()`:
        a **full-access company key** (`companies.api_key_hash` — every
        resource/action, no allow-list consulted), or a **scoped key**
        (`api_keys` table — `ai_agent` or `quick_capture`, each restricted
        to a fixed resource/action allow-list; an out-of-scope call gets
        the identical 401 an invalid key would, so scope can't be probed).
        A suspended company's key of either tier is rejected the same way
        (Hub Task #9966) — see
        [hub-agent-api-capability-guide.md §3.2](hub-agent-api-capability-guide.md#32-api-key-authentication-includesapi_authphp).
        Full detail: [docs/api-auth-guide.md](api-auth-guide.md).
    SessionCookie:
      type: apiKey
      in: cookie
      name: PHPSESSID
      description: >
        Browser session cookie, validated on every request against
        `hub.sessions` (no caching/fast-path) — the same mechanism every
        Hub page uses. When present with no `X-API-Key` header, `api.php`
        treats the caller as full-access, scoped to the session's own
        company. A suspended company's session is rejected identically to
        an invalid one (Hub Task #9966), and re-checked on every single
        request — an existing session stops working the moment its company
        is suspended, no logout required.

  parameters:
    IdQuery:
      name: id
      in: query
      required: true
      schema: { type: integer }
      description: The resource's own numeric id — meaning depends on which resource this parameter is attached to.
    DocumentIdQuery:
      name: document_id
      in: query
      required: true
      schema: { type: integer }

  responses:
    NotFound:
      description: Not found (includes rows belonging to another company — cross-tenant existence is never disclosed)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: >
        Missing, invalid, inactive, or wrong-scope API key; no session; or
        a valid key/session whose owning company is suspended (Hub Task
        #9966) — all four cases return this identical response, by design.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Caller is authenticated but lacks the required privilege for this action (e.g. data_reset without is_admin=1).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ModuleNotEnabled:
      description: The resource's Hub module is not entitled for the caller's company.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              module: { type: string, description: "The HUB_MODULES key that would need to be enabled." }
    BadRequest:
      description: Invalid input — missing required field, or a value outside the allowed set.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    VersionConflictTask:
      description: Optimistic-concurrency conflict — the task changed since the caller last read it.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              current_task: { $ref: "#/components/schemas/Task" }
    VersionConflictDocument:
      description: Optimistic-concurrency conflict — the document changed since the caller last read it.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              current_document: { $ref: "#/components/schemas/Document" }
    VersionConflictSong:
      description: Optimistic-concurrency conflict — the song changed since the caller last read it.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              current_song: { $ref: "#/components/schemas/Song" }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string }

    Project:
      type: object
      properties:
        id: { type: integer }
        company_id: { type: integer }
        name: { type: string }
        description: { type: string, nullable: true }
        status: { type: string, enum: [active, on-hold, completed, archived] }
        display: { type: integer }
        client: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string }

    Task:
      type: object
      properties:
        id: { type: integer }
        project_id: { type: integer }
        title: { type: string }
        description: { type: string, nullable: true }
        tags: { type: string, nullable: true }
        status: { type: string, enum: [todo, in-progress, done, blocked] }
        priority: { type: string, enum: [low, normal, high, urgent] }
        category: { type: string, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        delivery_mode:
          type: string
          enum: [commit_push, commit_only, no_commit]
          description: >
            Per-task policy an executing coding agent follows for finished,
            validated work. commit_push = commit and push; commit_only =
            commit but do not push; no_commit = leave uncommitted and report
            for review. New tasks default to commit_push; legacy tasks were
            migrated to no_commit. See
            docs/claude-code-task-execution-standard.md.
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string }

    TaskDetail:
      allOf:
        - { $ref: "#/components/schemas/Task" }
        - type: object
          properties:
            comments: { type: array, items: { $ref: "#/components/schemas/TaskComment" }, description: "Full ordered history, oldest first." }
            images: { type: array, items: { $ref: "#/components/schemas/Image" } }

    TaskComment:
      type: object
      description: "Ordinary task history comment; no structured progress/completion/blocker type exists."
      properties:
        id: { type: integer }
        task_id: { type: integer }
        author: { type: string, nullable: true }
        body: { type: string }
        created_at: { type: string, format: date-time }

    Image:
      type: object
      properties:
        id: { type: integer }
        task_id: { type: integer }
        file_name: { type: string }
        file_path: { type: string, description: "Web-relative path — served directly, unlike document attachments." }
        created_at: { type: string, format: date-time }

    Interaction:
      type: object
      description: >
        Row from the separate `interactions` database, not `hub`. Only
        reachable now via `projects.get-with-context`'s embedded read
        (§ above) — the standalone `interactions` resource was retired,
        Hub Task #10088. This schema describes the 3 remaining historical
        rows, not an active, growing dataset.
      properties:
        id: { type: integer }
        project_id: { type: integer, nullable: true }
        task_id: { type: integer, nullable: true }
        session_id: { type: string, nullable: true }
        ai_provider: { type: string }
        model: { type: string, nullable: true }
        user_message: { type: string }
        ai_response: { type: string }
        tokens_used: { type: integer, nullable: true }
        estimated_cost: { type: number, nullable: true }
        metadata: { type: object, nullable: true }
        timestamp: { type: string, format: date-time }

    Briefing:
      type: object
      properties:
        id: { type: integer }
        company_id: { type: integer }
        briefing_date: { type: string, format: date }
        content_markdown: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Idea:
      type: object
      properties:
        id: { type: integer }
        company_id: { type: integer }
        title: { type: string }
        description: { type: string, nullable: true }
        status: { type: string, enum: [suggested, idea, reviewing, ready] }
        created_by: { type: string, nullable: true }
        suggested_by: { type: string, nullable: true }
        suggested_reason: { type: string, nullable: true }
        promoted_task_id: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, description: "Always the Ideas list view — no per-idea browser page exists yet." }

    SearchResult:
      type: object
      description: >
        Common shape across every resource type search can return. Fields
        beyond type/id/title/matched_fields/url are type-specific today
        (project_id/project_name/status/priority/description_excerpt are
        the 'task' shape) — a future type keeps this same envelope and
        adds its own type-specific fields the same way.
      properties:
        type: { type: string, enum: [task] }
        id: { type: integer }
        project_id: { type: integer }
        project_name: { type: string }
        title: { type: string }
        description_excerpt: { type: string, nullable: true }
        status: { type: string, enum: [todo, in-progress, done, blocked] }
        priority: { type: string, enum: [low, normal, high, urgent] }
        matched_fields:
          type: array
          items: { type: string, enum: [id, title, description] }
        url: { type: string }

    SearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items: { $ref: "#/components/schemas/SearchResult" }
        pagination: { $ref: "#/components/schemas/Pagination" }

    Pagination:
      type: object
      description: Shape shared by every paginated list response in this API (search today; activity_events/document_share_views use a simpler limit-only form without has_more).
      properties:
        limit: { type: integer }
        offset: { type: integer }
        has_more: { type: boolean }

    CompanySettings:
      type: object
      properties:
        success: { type: boolean }
        default_app: { type: string }
        modules:
          type: array
          description: Full registry metadata for every module this company is entitled to and that's preference-eligible.
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              url: { type: string }
              kind: { type: string, enum: [hub_module, cross_app] }
              visible: { type: boolean }
        enabled_apps:
          type: array
          items: { type: string }
          description: "Legacy field — cross-app kind only, entitled ∩ visible. New consumers should read `modules` instead."

    DocumentFolder:
      type: object
      properties:
        id: { type: integer }
        company_id: { type: integer }
        parent_id: { type: integer, nullable: true }
        name: { type: string }
        sort_order: { type: integer }
        created_by: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DocumentListItem:
      type: object
      description: The shape returned by documents.list/search — no markdown_content (use documents.get for full content).
      properties:
        id: { type: integer }
        company_id: { type: integer }
        folder_id: { type: integer, nullable: true }
        project_id: { type: integer, nullable: true }
        title: { type: string }
        document_type:
          type: string
          enum: [document, journal]
          description: App-validated (DOCUMENT_TYPES), not a DB ENUM.
        journal_date: { type: string, format: date, nullable: true, description: "Only set when document_type=journal." }
        summary: { type: string, nullable: true }
        version: { type: integer }
        created_by: { type: integer, nullable: true }
        updated_by: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        share_view_count: { type: integer, description: "Lifetime sum of access_count across every share of this document." }
        share_last_viewed_at: { type: string, format: date-time, nullable: true }

    Document:
      allOf:
        - { $ref: "#/components/schemas/DocumentListItem" }
        - type: object
          properties:
            markdown_content: { type: string }

    DocumentRevisionSummary:
      type: object
      description: document_revisions.list shape — no markdown_snapshot.
      properties:
        id: { type: integer }
        document_id: { type: integer }
        company_id: { type: integer }
        revision_number: { type: integer }
        title_snapshot: { type: string }
        created_by: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }

    DocumentRevisionDetail:
      allOf:
        - { $ref: "#/components/schemas/DocumentRevisionSummary" }
        - type: object
          properties:
            markdown_snapshot: { type: string }

    DocumentAttachment:
      type: object
      properties:
        id: { type: integer }
        document_id: { type: integer }
        company_id: { type: integer }
        original_name: { type: string }
        display_name: { type: string, nullable: true, description: "Author-facing rename; NULL falls back to original_name at read time." }
        mime_type: { type: string }
        file_size: { type: integer }
        uploaded_by: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }

    DocumentTaskLink:
      type: object
      properties:
        link_id: { type: integer }
        task_id: { type: integer }
        linked_at: { type: string, format: date-time }
        resolved: { type: boolean, description: "False when the linked task was hard-deleted — the link row is preserved regardless." }
        task:
          allOf: [{ $ref: "#/components/schemas/Task" }]
          description: "Null when resolved=false (OpenAPI 3.0 has no clean way to express \"ref or null\" without a sibling type — documented in prose instead of a nullable keyword)."

    OutreachLogEntry:
      type: object
      properties:
        id: { type: integer }
        document_id: { type: integer }
        task_id: { type: integer, nullable: true }
        outreach_type: { type: string, enum: [cold_outreach, application, follow_up, reconnection] }
        company_name: { type: string }
        contact_name: { type: string, nullable: true }
        subject: { type: string, nullable: true }
        shared_document_ids: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        planned_followup_date: { type: string, format: date, nullable: true }
        occurred_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    ActivityEvent:
      type: object
      description: >
        Append-only — no update or delete action exists for this resource.
        See
        [hub-agent-api-capability-guide.md §9](hub-agent-api-capability-guide.md#9-activity-logging)
        for the full model, including how this differs from task comments,
        task_changes, and Daily Journal entries.
      properties:
        id: { type: integer }
        project_id: { type: integer, nullable: true }
        task_id: { type: integer, nullable: true }
        document_id: { type: integer, nullable: true }
        event_type: { type: string, enum: [work_started, progress, decision, task_created, task_completed, bug_found, commit_pushed, meeting, note] }
        summary: { type: string }
        decisions: { type: string, nullable: true }
        files_affected: { type: string, nullable: true }
        next_step: { type: string, nullable: true }
        session_title: { type: string, nullable: true }
        performed_by_type: { type: string, enum: [user, agent, system, integration] }
        performed_by: { type: string, nullable: true }
        source_reference: { type: string, nullable: true }
        context_json: { type: object, nullable: true }
        client_dedupe_key: { type: string, nullable: true }
        occurred_at: { type: string, format: date-time, description: "Server-authoritative — set at insert time, never client-overridable." }
        client_occurred_at: { type: string, format: date-time, nullable: true, description: "What the caller reported — never authoritative." }
        created_at: { type: string, format: date-time }

    DataResetLogEntry:
      type: object
      properties:
        id: { type: integer }
        company_id: { type: integer }
        app: { type: string }
        module: { type: string }
        table_name: { type: string }
        action: { type: string, enum: [backup, reset] }
        backup_file_path: { type: string, nullable: true }
        row_count: { type: integer }
        performed_by: { type: string }
        created_at: { type: string, format: date-time }

    DocumentShare:
      type: object
      description: token_hash/passcode_hash are never included in any response — has_passcode is a derived boolean instead.
      properties:
        id: { type: integer }
        document_id: { type: integer }
        company_id: { type: integer }
        has_passcode: { type: boolean }
        status: { type: string, enum: [active, expired, revoked] }
        recipient_label: { type: string, nullable: true }
        share_title: { type: string, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }
        reset_at: { type: string, format: date-time, nullable: true }
        created_by_user_id: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        last_accessed_at: { type: string, format: date-time, nullable: true }
        views_lifetime: { type: integer, description: "Total access_count — never reset." }
        views_since_reset: { type: integer, description: "Counted views since reset_at (or created_at if never reset)." }
        unique_visitors: { type: integer }
        first_viewed_at: { type: string, format: date-time, nullable: true }
        distinct_days_viewed: { type: integer, description: "Bucketed by America/Chicago calendar day." }
        max_engaged_seconds: { type: integer, nullable: true }
        engagement_state: { type: string, enum: [not_opened, opened, viewed_briefly, engaged, returned] }
        engagement_label: { type: string, description: "Human-readable label for engagement_state, e.g. \"Returned\"." }

    DocumentShareWithToken:
      allOf:
        - { $ref: "#/components/schemas/DocumentShare" }
        - type: object
          properties:
            token: { type: string, description: "The raw share token — shown ONLY in the create/regenerate response, never again." }

    DocumentShareView:
      type: object
      properties:
        id: { type: integer }
        viewed_at: { type: string, format: date-time }
        visitor_label: { type: string, description: "First 10 characters of the visitor's per-share SHA-256 hash — enough to group same-visitor rows, not reversible to an identity." }
        counted: { type: boolean }
        exclude_reason: { type: string, nullable: true, enum: [bot, owner, null] }
        engaged_seconds: { type: integer, nullable: true, description: "Null if no reading-time beacon ever landed for this view." }

    Song:
      type: object
      properties:
        id: { type: integer }
        company_id: { type: integer }
        created_by: { type: integer, nullable: true }
        title: { type: string }
        status: { type: string, enum: [idea, in_progress, lyrics_complete] }
        lyrics: { type: string, description: "Flattened from the current song_sections row — Phase 1A always has exactly one section." }
        version: { type: integer }
        archived_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SongListItem:
      allOf:
        - type: object
          properties:
            id: { type: integer }
            company_id: { type: integer }
            created_by: { type: integer, nullable: true }
            title: { type: string }
            status: { type: string, enum: [idea, in_progress, lyrics_complete] }
            version: { type: integer }
            archived_at: { type: string, format: date-time, nullable: true }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }
            lyrics_preview: { type: string, description: "First 200 characters — not full lyrics." }

    SongRevisionSummary:
      type: object
      properties:
        id: { type: integer }
        song_id: { type: integer }
        company_id: { type: integer }
        revision_number: { type: integer }
        title: { type: string }
        status: { type: string, enum: [idea, in_progress, lyrics_complete] }
        revision_note: { type: string, nullable: true, description: "Set only on a restore, e.g. \"Restored from revision 3\"." }
        created_by: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        lyrics_preview: { type: string }

    SongRevisionDetail:
      type: object
      properties:
        id: { type: integer }
        song_id: { type: integer }
        company_id: { type: integer }
        revision_number: { type: integer }
        title: { type: string }
        status: { type: string, enum: [idea, in_progress, lyrics_complete] }
        revision_note: { type: string, nullable: true }
        created_by: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        sections:
          type: array
          items:
            type: object
            properties:
              section_type: { type: string }
              section_label: { type: string }
              position: { type: integer }
              lyrics: { type: string }
        lyrics: { type: string, description: "Flattened from the first section, for convenience." }
