openapi: 3.1.0
info:
  title: Moyro Native API
  version: 0.2.52
  description: |
    Moyro Flow, durable activity inbox, lifecycle-aware work items, message
    automation, permission-scoped knowledge retrieval, conversation-derived
    documents, safe approval previews, and service policy endpoints under
    `/api/moyro/v1`.
    This contract is intentionally separate from `openapi-v4.yaml`, which
    documents the Mattermost-compatible `/api/v4` boundary.

    First-party browser requests use an HttpOnly same-origin session cookie;
    API, PAT, and managed-key clients use `Authorization: Bearer`. Protected
    native endpoints reject credentials supplied through the `access_token`
    query parameter. Cookie-authenticated mutations also require the configured
    same-origin `Origin` value.
servers:
  - url: /api/moyro/v1
    description: Native API on the current Moyro deployment origin
tags:
  - name: Authentication
    description: Browser-bound completion of an external identity login.
  - name: OIDC
    description: Public browser sign-in and administrator-managed Keycloak configuration.
  - name: System
    description: Public build metadata and client capabilities.
  - name: Settings
    description: Administrator-owned site and browser policy.
  - name: Operations
    description: Evidence-backed administrator diagnostics for durable runtime state.
  - name: Flow
    description: User-scoped navigation and unread read models.
  - name: Activity
    description: Durable personal inbox events and their state.
  - name: Work items
    description: Tasks and decisions derived from accessible conversations.
  - name: Automations
    description: User-owned message rules and durable execution history.
  - name: Knowledge
    description: Permission-scoped message and document retrieval with citation sources.
  - name: Documents
    description: Source-watermarked documents derived from live conversation threads.
  - name: Inbox preferences
    description: User-owned priority, bundling, snooze, and work-hours rules.
  - name: OIDC onboarding
    description: Administrator-managed identity-provider group onboarding targets.
  - name: Approvals
    description: Protected MCP requests and display-safe review projections.
security:
  - bearerAuth: []
  - browserSession: []
paths:
  /auth/session/login:
    post:
      operationId: createBrowserSession
      tags: [Authentication]
      security: []
      summary: Authenticate the first-party web client with an HttpOnly session
      description: |
        Same-origin browser endpoint. The reusable session credential is set in
        an HttpOnly, SameSite cookie and is never returned in JSON or a response
        token header. API clients should continue using `/api/v4/users/login`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [login_id, password]
              properties:
                login_id: { type: string }
                password: { type: string, format: password }
                device_id: { type: string }
      responses:
        "201":
          description: Browser session created
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
            Set-Cookie:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BrowserLoginSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
  /auth/session/adopt:
    post:
      operationId: adoptLegacyBrowserSession
      tags: [Authentication]
      security:
        - bearerAuth: []
      summary: Move a legacy browser bearer session into an HttpOnly cookie
      description: |
        One-release migration endpoint for an existing browser session stored
        by an older web client. A valid session Bearer token is required once;
        the response sets the browser cookie and does not echo the credential.
      responses:
        "200":
          description: Browser session adopted
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
            Set-Cookie:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BrowserLoginSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /auth/session/logout:
    post:
      operationId: revokeBrowserSession
      tags: [Authentication]
      summary: Revoke the current session and clear its browser cookie
      responses:
        "200":
          description: Session revoked
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [status]
                properties:
                  status: { type: string, const: OK }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /auth/oidc/login:
    get:
      operationId: beginOIDCLogin
      tags: [OIDC]
      security: []
      summary: Begin a browser-bound Keycloak authorization-code flow
      description: |
        Captures the active immutable provider snapshot and onboarding policy,
        creates a one-time PKCE transaction, and redirects the browser to the
        provider. The transaction is also bound to an HttpOnly cookie. An
        optional return path is accepted only when it is a safe same-origin
        absolute path; invalid values are replaced by `/`. With `prompt=none`
        and the provider's `auto_login` setting on, the authorization request
        carries `prompt=none` so the provider answers from an existing session
        only; when `auto_login` is off the parameter is ignored.
      parameters:
        - name: return_to
          in: query
          required: false
          description: Same-origin absolute path to restore after successful login.
          schema: { type: string, maxLength: 2048, example: "/work" }
        - name: prompt
          in: query
          required: false
          description: "`none` requests a silent attempt; honoured only while `auto_login` is on."
          schema: { type: string, enum: [none] }
      responses:
        "302":
          description: Redirect to the provider authorization endpoint
          headers:
            Location:
              schema: { type: string, format: uri }
            Cache-Control:
              schema: { type: string, const: no-store }
            Set-Cookie:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /auth/oidc/callback:
    get:
      operationId: completeOIDCCallback
      tags: [OIDC]
      security: []
      summary: Complete the Keycloak callback and issue a browser handoff code
      description: |
        Consumes the state and HttpOnly transaction cookie, exchanges the
        authorization code against the provider snapshot captured at login,
        resolves the local account, and applies the captured group mappings.
        Every success or failure is returned as a `302`: success appends a
        five-minute opaque `sso_code` to the destination URL fragment and sets
        its independent HttpOnly binding cookie; failure redirects to
        `/login#oauth_error=<safe-code>`. A `login_required`,
        `interaction_required`, or `consent_required` answer to a flow this
        server started with `prompt=none` is not a failure: it redirects to
        `/login?sso=none` (plus the safe `return_to`) so the browser shows the
        login screen and does not retry. Call `POST /auth/sso/session` next to
        create the reusable local browser session.
      parameters:
        - name: state
          in: query
          description: Opaque state returned by the provider.
          schema: { type: string }
        - name: code
          in: query
          description: Authorization code returned on provider success.
          schema: { type: string }
        - name: error
          in: query
          description: Provider error code returned instead of `code`.
          schema: { type: string }
        - name: error_description
          in: query
          description: Provider-supplied diagnostic text; never forwarded verbatim to the browser fragment.
          schema: { type: string }
      responses:
        "302":
          description: Redirect to the requested local path or to the login error view
          headers:
            Location:
              schema: { type: string }
            Cache-Control:
              schema: { type: string, const: no-store }
            Set-Cookie:
              description: Clears the transaction cookie and, on success, sets the handoff binding.
              schema: { type: string }
  /auth/sso/session:
    post:
      operationId: exchangeBrowserSSOSession
      tags: [Authentication]
      security: []
      summary: Exchange a browser-bound SSO code for an HttpOnly local session
      description: |
        OAuth and OIDC callbacks place a five-minute opaque code in the browser
        fragment and an independent binding in an HttpOnly, path-restricted
        cookie. Both must match the same live PostgreSQL row. A successful
        exchange atomically creates a session and returns only the local user;
        the reusable credential is set only in an HttpOnly cookie. The same
        browser binding can recover the identical session for 60 seconds if a
        successful response is lost, without minting a second session.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [code]
              properties:
                code: { type: string, minLength: 43, maxLength: 43, pattern: "^[A-Za-z0-9_-]+$" }
      responses:
        "200":
          description: Authenticated
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
            Set-Cookie:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BrowserLoginSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
  /system/info:
    get:
      operationId: getSystemInfo
      tags: [System]
      summary: Read public build and capability information
      description: |
        Returns only public service metadata. Draft capability values are the
        effective administrator policy consumed by the web client.
      security: []
      responses:
        "200":
          description: Public service information
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SystemInfo"
  /admin/settings/site:
    get:
      operationId: getSiteSettings
      tags: [Settings]
      summary: Read site and browser-draft policy
      description: Requires the `manage_settings` permission.
      responses:
        "200":
          description: Current site settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SiteSettings"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    patch:
      operationId: updateSiteSettings
      tags: [Settings]
      summary: Replace site and browser-draft policy
      description: |
        Requires `manage_settings`. Although the method is PATCH, the handler
        decodes a complete settings view over server defaults. Send every field
        to avoid unintentionally restoring an omitted value to its default.
        `public_base_url` cannot be cleared while Keycloak is enabled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SiteSettings"
      responses:
        "200":
          description: Normalized and stored site settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SiteSettings"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /admin/operations:
    get:
      operationId: getAdminOperations
      tags: [Operations]
      summary: Read evidence-backed operational state
      description: |
        Requires `manage_system`. Database, migration, queue, webhook-delivery,
        and file-metadata values come from the current PostgreSQL state and
        selected storage runtime. A subsystem remains `unknown` when Moyro has
        no heartbeat or connection probe that can prove it healthy; an empty
        queue is not reported as a running worker.
      responses:
        "200":
          description: Current operational snapshot
          headers:
            Cache-Control:
              schema: { type: string, const: "private, no-store" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminOperationsSnapshot"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /admin/oidc/providers:
    get:
      operationId: listOIDCProviders
      tags: [OIDC]
      summary: List configured OIDC providers
      description: |
        Requires `manage_oidc`. Moyro currently supports one provider with the
        stable id `keycloak`; an unconfigured deployment returns an empty array.
        Stored client-secret material is never returned.
      responses:
        "200":
          description: Configured providers, or an empty array
          content:
            application/json:
              schema:
                type: array
                maxItems: 1
                items: { $ref: "#/components/schemas/OIDCProvider" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    post:
      operationId: createOIDCProvider
      tags: [OIDC]
      summary: Save the Keycloak provider configuration
      description: |
        Requires `manage_oidc`. Omitted identity and claim fields use Keycloak
        defaults. Enabling the provider requires a configured public base URL,
        client secret, and successful discovery. Discovery is completed before
        settings are committed and the exact prepared snapshot is then activated.
        Sending an empty `client_secret` preserves the stored secret.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OIDCProviderSaveRequest" }
      responses:
        "200":
          description: Normalized and stored provider
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OIDCProvider" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /admin/oidc/providers/{providerID}:
    parameters:
      - $ref: "#/components/parameters/OIDCProviderID"
    patch:
      operationId: updateOIDCProvider
      tags: [OIDC]
      summary: Replace the Keycloak provider configuration
      description: |
        Requires `manage_oidc`. Although the method is PATCH, the handler
        decodes a complete view over server defaults; clients should send every
        editable field. An empty `client_secret` preserves the stored secret.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OIDCProviderSaveRequest" }
      responses:
        "200":
          description: Normalized and stored provider
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OIDCProvider" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /admin/oidc/providers/test:
    post:
      operationId: testOIDCProvider
      tags: [OIDC]
      summary: Probe OIDC discovery without storing or activating the provider
      description: |
        Requires `manage_oidc`. The probe validates the issuer and discovery
        document. It does not require or test the client secret. Insecure HTTP
        discovery is rejected unless `allow_insecure_backchannel` is explicitly
        enabled for an offline or development deployment.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OIDCProviderSaveRequest" }
      responses:
        "200":
          description: Discovery succeeded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OIDCProviderTestResult" }
        "400":
          description: Invalid request or discovery failed
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/Error" }
                  - { $ref: "#/components/schemas/OIDCProviderTestResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /admin/oidc/onboarding-targets:
    get:
      operationId: listOIDCOnboardingTargets
      tags: [OIDC onboarding]
      summary: List active teams and channels available to OIDC group mappings
      description: |
        Requires `manage_oidc`. Archived teams/channels and direct or group
        message channels are omitted, so saved mappings cannot target them.
      responses:
        "200":
          description: Current mapping destinations
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OIDCOnboardingTargets" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/flow-summary:
    get:
      operationId: getFlowSummary
      tags: [Flow]
      summary: Read the current user's Flow navigation model
      description: |
        Returns accessible teams, active team channels, membership counters,
        and up to six priority unread channels in one request. Browser sessions
        use their live memberships. Restricted API keys additionally require
        the current `mcp_read` grant and remain bounded by their team/channel
        scopes. Direct and group-message channels are not included in this read
        model.
      responses:
        "200":
          description: User-scoped Flow summary
          headers:
            Cache-Control:
              schema: { type: string, const: "private, no-store" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FlowSummary"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/activity-events:
    get:
      operationId: listActivityEvents
      tags: [Activity]
      summary: List durable activity inbox events
      description: |
        Events are always constrained to the authenticated owner. Channel-bound
        events disappear from subsequent reads if that user loses access to the
        channel. The cursor is opaque and orders rows by `(create_at, id)`
        descending. Repeated `type` parameters and comma-separated type values
        are both accepted.
      parameters:
        - $ref: "#/components/parameters/ActivityCursor"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: unread
          in: query
          schema: { type: boolean, default: false }
        - name: type
          in: query
          description: Event type filter; repeat the parameter to select several types.
          style: form
          explode: true
          schema:
            type: array
            items: { $ref: "#/components/schemas/ActivityEventType" }
      responses:
        "200":
          description: Cursor page of safe, owner-scoped activity views
          headers:
            Cache-Control:
              schema: { type: string, const: "private, no-store" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActivityEventPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/activity-events/{eventID}:
    patch:
      operationId: updateActivityEventState
      tags: [Activity]
      summary: Update read, completed, or snooze state
      description: |
        Only the authenticated owner's row can be changed. Foreign and
        inaccessible channel events are returned as not found.
      parameters:
        - $ref: "#/components/parameters/EventID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ActivityStatePatch"
      responses:
        "200":
          description: Updated activity event
          headers:
            Cache-Control:
              schema: { type: string, const: "private, no-store" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActivityEvent"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/activity-events/mark-read:
    post:
      operationId: markActivityEventsRead
      tags: [Activity]
      summary: Mark several activity events read
      description: |
        Updates at most 200 owner-scoped events. Foreign, missing, or no-longer
        accessible event IDs are indistinguishable no-ops and are not included
        in the returned count.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ActivityMarkReadRequest"
      responses:
        "200":
          description: Number of rows newly marked read
          headers:
            Cache-Control:
              schema: { type: string, const: "private, no-store" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActivityMarkReadResult"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/work-items:
    get:
      operationId: listWorkItems
      tags: [Work items]
      summary: List accessible tasks and decisions
      description: |
        Tasks are visible only to their creator or current assignee. Decisions
        are visible to current members of the source channel. Removed channel
        membership removes both kinds from future reads. When `status` is
        supplied, a matching `kind` is required. `sort=due` is available for
        dated tasks; otherwise the cursor sorts by `(create_at, id)` descending.
      parameters:
        - name: kind
          in: query
          schema: { $ref: "#/components/schemas/WorkItemKind" }
        - name: status
          in: query
          description: Must be valid for the accompanying kind.
          schema: { $ref: "#/components/schemas/WorkItemStatus" }
        - name: cursor
          in: query
          schema: { type: string, maxLength: 1024 }
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 40 }
        - name: due_from
          in: query
          schema: { type: integer, format: int64, minimum: 0 }
        - name: due_to
          in: query
          schema: { type: integer, format: int64, minimum: 0 }
        - name: sort
          in: query
          schema: { type: string, enum: [created, due], default: created }
      responses:
        "200":
          description: Cursor page of work items
          headers:
            Cache-Control:
              schema: { type: string, const: "private, no-store" }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkItemPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    post:
      operationId: createWorkItem
      tags: [Work items]
      summary: Create a task or record a decision from a message
      description: |
        The server derives team, channel, and root-thread scope from the live
        source post and rechecks source-channel membership. A task assignee must
        be an active channel member; an omitted task assignee defaults to the
        caller. Tasks can carry priority, recurrence, and accessible task
        dependencies. Decisions use proposal/review/record lifecycle fields,
        may supersede an existing recorded decision, and can identify impacted
        accessible tasks.

        An idempotency key of 1–200 characters is required in either the
        `Idempotency-Key` header or `idempotency_key` body field. If both are
        sent they must match. Replaying the same actor/key with every durable
        input unchanged returns `200` and `replayed: true`; reuse for different
        input or a deleted item returns `409`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWorkItem"
      responses:
        "201":
          description: Newly created work item
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateWorkItemResult"
        "200":
          description: Idempotent replay of the original work item
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateWorkItemResult"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/work-items/{workItemID}:
    patch:
      operationId: updateWorkItem
      tags: [Work items]
      summary: Update a task or decision
      description: |
        The creator can change owner-controlled fields. A current task assignee
        can change status only. A configured decision reviewer may advance the
        lifecycle but cannot change owner fields. Task completion is rejected
        while a non-terminal dependency remains. Guessed task IDs that are not
        visible to the caller are returned as not found.
      parameters:
        - $ref: "#/components/parameters/WorkItemID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PatchWorkItem"
      responses:
        "200":
          description: Updated work item
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WorkItem"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    delete:
      operationId: deleteWorkItem
      tags: [Work items]
      summary: Delete a work item
      description: Only the creator, while still a source-channel member, can delete it.
      parameters:
        - $ref: "#/components/parameters/WorkItemID"
      responses:
        "204":
          description: Work item deleted; no response body
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/work-items/{workItemID}/events:
    get:
      operationId: listWorkItemEvents
      tags: [Work items]
      summary: Read the immutable lifecycle history of a visible work item
      parameters:
        - $ref: "#/components/parameters/WorkItemID"
      responses:
        "200":
          description: Chronological lifecycle events
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WorkItemEvent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/work-items/{workItemID}/dependencies/{dependencyID}:
    parameters:
      - $ref: "#/components/parameters/WorkItemID"
      - $ref: "#/components/parameters/DependencyID"
    post:
      operationId: addWorkItemDependency
      tags: [Work items]
      summary: Add an accessible task dependency without creating a cycle
      responses:
        "200":
          description: Updated task
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkItem" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    delete:
      operationId: removeWorkItemDependency
      tags: [Work items]
      summary: Remove a task dependency
      responses:
        "200":
          description: Updated task
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkItem" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/work-items/{workItemID}/impacts/{taskID}:
    parameters:
      - $ref: "#/components/parameters/WorkItemID"
      - $ref: "#/components/parameters/TaskID"
    post:
      operationId: addDecisionImpact
      tags: [Work items]
      summary: Link a decision to an accessible impacted task
      responses:
        "200":
          description: Updated decision
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkItem" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    delete:
      operationId: removeDecisionImpact
      tags: [Work items]
      summary: Remove an impacted-task link
      responses:
        "200":
          description: Updated decision
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkItem" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/automation-rules:
    get:
      operationId: listAutomationRules
      tags: [Automations]
      summary: List message rules owned by the current user
      responses:
        "200":
          description: Active rules with their ordered actions
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AutomationRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    post:
      operationId: createAutomationRule
      tags: [Automations]
      summary: Create a bounded channel-message automation
      description: |
        The owner must currently belong to the source channel. A matching post
        and its action-run rows commit atomically; the offline worker retries
        failed actions using stable run identities.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SaveAutomationRule" }
      responses:
        "201":
          description: Rule created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomationRule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/automation-rules/{ruleID}:
    parameters:
      - $ref: "#/components/parameters/AutomationRuleID"
    patch:
      operationId: updateAutomationRule
      tags: [Automations]
      summary: Replace a rule using its expected revision
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateAutomationRule" }
      responses:
        "200":
          description: Updated rule
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomationRule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    delete:
      operationId: deleteAutomationRule
      tags: [Automations]
      summary: Disable a rule and cancel its pending runs
      responses:
        "204": { description: Rule deleted; no response body }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/automation-rules/{ruleID}/runs:
    get:
      operationId: listAutomationRuns
      tags: [Automations]
      summary: List durable execution history for an owned rule
      parameters:
        - $ref: "#/components/parameters/AutomationRuleID"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 30 }
      responses:
        "200":
          description: Newest execution runs first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AutomationRun" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/knowledge/search:
    post:
      operationId: searchKnowledge
      tags: [Knowledge]
      summary: Search live accessible messages and derived documents
      description: |
        Search is performed entirely in PostgreSQL and re-evaluates current
        team/channel membership and restricted-key allow-lists. References such
        as `M1` and `D1` identify authoritative source cards in this response.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KnowledgeSearchInput" }
      responses:
        "200":
          description: Permission-filtered ranked sources
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KnowledgeSearchResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/documents:
    get:
      operationId: listDocuments
      tags: [Documents]
      summary: List conversation-derived documents still visible to the user
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Visible documents with live stale-source status
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    post:
      operationId: createDocument
      tags: [Documents]
      summary: Create a document from an unchanged live conversation thread
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDocument" }
      responses:
        "200":
          description: Idempotent replay
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateDocumentResult" }
        "201":
          description: Document created with a source snapshot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateDocumentResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/documents/{documentID}:
    parameters:
      - $ref: "#/components/parameters/DocumentID"
    get:
      operationId: getDocument
      tags: [Documents]
      summary: Read one currently accessible document
      responses:
        "200":
          description: Document with live stale-source status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    patch:
      operationId: updateDocument
      tags: [Documents]
      summary: Update owned content using optimistic revision control
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchDocument" }
      responses:
        "200":
          description: Updated document
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    delete:
      operationId: deleteDocument
      tags: [Documents]
      summary: Delete an owned document using optimistic revision control
      parameters:
        - name: revision
          in: query
          required: true
          schema: { type: integer, format: int64, minimum: 1 }
      responses:
        "204": { description: Document deleted; no response body }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/document-sources/{postID}:
    get:
      operationId: getDocumentSource
      tags: [Documents]
      summary: Resolve a post to its complete live source thread
      parameters:
        - $ref: "#/components/parameters/PostID"
      responses:
        "200":
          description: Ordered source posts and opaque content revision
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentSource" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/inbox-preferences:
    get:
      operationId: getInboxPreferences
      tags: [Inbox preferences]
      summary: Read normalized personal inbox and notification rules
      responses:
        "200":
          description: Stored preferences or offline-safe defaults
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InboxPreferences" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
    patch:
      operationId: updateInboxPreferences
      tags: [Inbox preferences]
      summary: Partially update personal priority, bundling, snooze, and work-hours rules
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PatchInboxPreferences" }
      responses:
        "200":
          description: Normalized saved preferences
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InboxPreferences" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /me/approval-requests:
    get:
      operationId: listOwnApprovalRequests
      tags: [Approvals]
      summary: List approval requests submitted by the current user
      description: |
        Returns display-oriented projections. Durable execution payloads and
        producer-private credential metadata are never included.
      parameters:
        - $ref: "#/components/parameters/ApprovalStatus"
      responses:
        "200":
          description: Up to 100 approval request views
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApprovalRequestView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: submitApprovalRequest
      tags: [Approvals]
      summary: Submit a supported protected MCP write
      description: |
        Requires a currently valid scoped MCP key with `mcp_write` and
        `request_approval` in the destination channel. The supported actions
        are `mcp.create_post` and `mcp.reply_to_thread`. The response contains
        a safe preview only; the raw execution payload is never returned.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitApprovalRequest"
      responses:
        "200":
          description: Approval requirement and optional safe request view
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApprovalResultView"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /reviews/approval-requests:
    get:
      operationId: listReviewApprovalRequests
      tags: [Approvals]
      summary: List approval requests the current principal can review
      description: |
        Team/channel scope and `review_approval` are evaluated for every row.
        Self-review exclusions configured by policy are also applied.
      parameters:
        - $ref: "#/components/parameters/ApprovalStatus"
      responses:
        "200":
          description: Up to 100 safe approval request views
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApprovalRequestView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }
  /reviews/approval-requests/{requestID}/decision:
    post:
      operationId: decideApprovalRequest
      tags: [Approvals]
      summary: Approve or reject an in-scope request
      description: |
        Requires current `review_approval` in the request resource scope. An
        approved request is executed through the MCP approved-action path
        before the response is returned. The response remains the safe view
        without the raw durable payload.
      parameters:
        - $ref: "#/components/parameters/ApprovalRequestID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ApprovalDecisionInput"
      responses:
        "200":
          description: Updated safe approval request view
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApprovalRequestView"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT, PAT, or scoped Moyro API key
    browserSession:
      type: apiKey
      in: cookie
      name: moyro_browser_session
  parameters:
    EventID:
      name: eventID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    WorkItemID:
      name: workItemID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    DependencyID:
      name: dependencyID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    TaskID:
      name: taskID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    OIDCProviderID:
      name: providerID
      in: path
      required: true
      description: Moyro currently supports the single built-in Keycloak provider.
      schema: { type: string, const: keycloak }
    AutomationRuleID:
      name: ruleID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    DocumentID:
      name: documentID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    PostID:
      name: postID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    ApprovalRequestID:
      name: requestID
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 128 }
    ActivityCursor:
      name: cursor
      in: query
      description: Opaque cursor returned as `next_cursor` by the preceding page.
      schema: { type: string, maxLength: 1024 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Required here or in the body; if both are sent, values must match.
      schema: { type: string, minLength: 1, maxLength: 200 }
    ApprovalStatus:
      name: status
      in: query
      schema:
        type: string
        enum: [pending, approved, rejected, expired, executed]
  responses:
    BadRequest:
      description: Invalid query, JSON body, cursor, or domain input
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, expired, invalid, or disallowed credential
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Current principal lacks the required permission or resource scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource is absent or intentionally hidden from this principal
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Optimistic revision, idempotency, dependency-cycle, or lifecycle precondition conflict
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: The complete live source thread exceeds the bounded snapshot size
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InternalError:
      description: Server could not complete the operation
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unavailable:
      description: Required native service is not configured or available
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      additionalProperties: false
      required: [id, message, status_code]
      properties:
        id: { type: string }
        message: { type: string }
        detailed_error: { type: string }
        request_id: { type: string }
        status_code: { type: integer, minimum: 400, maximum: 599 }
    LoginUser:
      type: object
      additionalProperties: false
      required: [id, username, email, roles, picture, guest_file_download]
      properties:
        id: { type: string }
        username: { type: string }
        email: { type: string }
        roles: { type: string }
        picture: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        nickname: { type: string }
        position: { type: string }
        guest_expires_at:
          type: integer
          format: int64
          minimum: 1
          description: Unix-millisecond expiry for a restricted guest; omitted for regular users.
        guest_file_download:
          type: boolean
          description: Whether a restricted guest may download original file bytes.
    BrowserLoginSession:
      type: object
      additionalProperties: false
      required: [user]
      properties:
        user: { $ref: "#/components/schemas/LoginUser" }
    OIDCSecretState:
      type: object
      additionalProperties: false
      required: [configured]
      properties:
        configured: { type: boolean }
    OIDCGroupMapping:
      type: object
      additionalProperties: false
      required: [group]
      properties:
        group:
          type: string
          minLength: 1
          maxLength: 255
          description: Exact provider group matched case-insensitively after trimming.
        account_role:
          type: string
          enum: [user, admin, guest]
          default: user
        team_id:
          type: string
          maxLength: 128
          description: Active team to add; required for mappings with channels and for guests.
        team_role:
          type: string
          enum: [member, admin]
          default: member
        channel_ids:
          type: array
          maxItems: 100
          uniqueItems: true
          items: { type: string, minLength: 1, maxLength: 128 }
        channel_role:
          type: string
          enum: [member, admin]
          default: member
        guest_expires_after_seconds:
          oneOf:
            - { type: integer, format: int64, const: 0 }
            - { type: integer, format: int64, minimum: 3600, maximum: 31536000 }
          description: For guest mappings, zero selects 30 days and explicit values must be 3600–31536000.
        guest_file_download:
          type: boolean
          default: false
      allOf:
        - if:
            properties:
              account_role: { const: guest }
            required: [account_role]
          then:
            required: [team_id, channel_ids]
            properties:
              team_id: { type: string, minLength: 1, maxLength: 128 }
              channel_ids: { type: array, minItems: 1, maxItems: 100 }
              team_role: { type: string, const: member }
              channel_role: { type: string, const: member }
    OIDCProviderSaveRequest:
      type: object
      description: The compatibility decoder ignores unknown fields; clients should send only the documented configuration properties.
      properties:
        id: { type: string, const: keycloak, default: keycloak }
        kind: { type: string, const: keycloak, default: keycloak }
        name: { type: string, default: Keycloak }
        enabled: { type: boolean, default: false }
        issuer_url:
          type: string
          description: Absolute HTTP(S) issuer URL; a trailing discovery-document suffix is normalized away.
        client_id: { type: string }
        client_secret:
          type: string
          format: password
          writeOnly: true
          description: Required when first enabling; an empty value preserves an existing encrypted secret.
        scopes:
          type: array
          items: { type: string }
          default: [openid, profile, email]
          description: Empty values and duplicates are removed; `openid` is always included.
        username_claim: { type: string, default: preferred_username }
        email_claim: { type: string, default: email }
        groups_claim: { type: string, default: groups }
        group_mappings:
          type: array
          maxItems: 100
          items: { $ref: "#/components/schemas/OIDCGroupMapping" }
        ca_certificate_pem:
          type: string
          description: Optional private CA bundle used only for provider backchannel requests.
        allow_signup: { type: boolean, default: true }
        require_verified_email: { type: boolean, default: true }
        allow_insecure_backchannel:
          type: boolean
          default: false
          description: Permit HTTP token, JWKS, and user-info endpoints for a trusted private network; authorization remains HTTPS-only when the issuer is HTTPS.
        auto_login:
          type: boolean
          default: false
          description: |
            Let the web app sign a visitor in silently with `prompt=none` when the
            provider still has a session. While this is off, `prompt=none` on the
            login endpoint is downgraded to an ordinary login.
    OIDCProvider:
      type: object
      additionalProperties: false
      required:
        - id
        - kind
        - name
        - enabled
        - issuer_url
        - client_id
        - scopes
        - username_claim
        - email_claim
        - groups_claim
        - group_mappings
        - allow_signup
        - require_verified_email
        - allow_insecure_backchannel
      properties:
        id: { type: string, const: keycloak }
        kind: { type: string, const: keycloak }
        name: { type: string }
        enabled: { type: boolean }
        issuer_url: { type: string }
        client_id: { type: string }
        client_secret_state: { $ref: "#/components/schemas/OIDCSecretState" }
        scopes:
          type: array
          items: { type: string }
        username_claim: { type: string }
        email_claim: { type: string }
        groups_claim: { type: string }
        group_mappings:
          type: array
          maxItems: 100
          items: { $ref: "#/components/schemas/OIDCGroupMapping" }
        ca_certificate_pem: { type: string }
        allow_signup: { type: boolean }
        require_verified_email: { type: boolean }
        allow_insecure_backchannel: { type: boolean }
        auto_login: { type: boolean }
        redirect_url: { type: string, format: uri }
        discovery_status: { type: string, enum: [unknown, ready, error] }
        last_tested_at: { type: integer, format: int64, minimum: 1 }
    OIDCProviderTestResult:
      type: object
      additionalProperties: false
      required: [ok]
      properties:
        ok: { type: boolean }
        issuer: { type: string }
        message: { type: string }
    SystemInfo:
      type: object
      required:
        - name
        - version
        - build_hash
        - build_date
        - oidc_enabled
        - approval_enabled
        - local_signup_enabled
        - capabilities
      properties:
        name: { type: string, const: moyro }
        version: { type: string }
        build_hash: { type: string }
        build_date: { type: string }
        oidc_enabled: { type: boolean }
        oidc_provider_name: { type: string }
        oidc_auto_login:
          type: boolean
          description: True only when OIDC is live and the administrator turned `auto_login` on.
        approval_enabled: { type: boolean }
        local_signup_enabled: { type: boolean }
        capabilities:
          $ref: "#/components/schemas/Capabilities"
    Capabilities:
      type: object
      required: [email_digest, drafts]
      properties:
        email_digest:
          type: object
          required: [configured, enabled]
          properties:
            configured: { type: boolean }
            enabled: { type: boolean }
        drafts:
          $ref: "#/components/schemas/DraftPolicy"
        mcp_oauth:
          type: object
          description: |
            Whether `/mcp` accepts Keycloak access tokens next to personal keys
            (`mcp.oauth.enabled`, resolved against the live OIDC provider and the
            resource identifier). The URLs are the ones an MCP client needs; the
            same values are public in `GET /.well-known/oauth-protected-resource/mcp`
            (outside this API prefix), which answers 404 while SSO is off.
          required: [enabled]
          properties:
            enabled: { type: boolean }
            mcp_url: { type: string, format: uri, description: The RFC 8707 resource identifier a client connects to. }
            metadata_url: { type: string, format: uri, description: The RFC 9728 protected-resource metadata document. }
    DraftPolicy:
      type: object
      required: [storage_mode, retention_days, clear_on_logout]
      properties:
        storage_mode:
          type: string
          enum: [local, session, disabled]
          description: Browser persistence, current browser session only, or no local draft storage.
        retention_days: { type: integer, minimum: 1, maximum: 30, default: 7 }
        clear_on_logout: { type: boolean, default: true }
    SiteSettings:
      type: object
      required:
        - site_name
        - public_base_url
        - allowed_outgoing_hosts
        - trusted_proxy_cidrs
        - local_signup_enabled
        - draft_storage_mode
        - draft_retention_days
        - draft_clear_on_logout
      properties:
        site_name: { type: string, minLength: 1, maxLength: 80 }
        public_base_url:
          description: Empty or an absolute HTTP(S) origin without path, query, credentials, or fragment.
          oneOf:
            - { type: string, const: "" }
            - { type: string, format: uri, pattern: "^https?://" }
        allowed_outgoing_hosts:
          type: array
          maxItems: 256
          items:
            type: string
            description: Hostname or IP address without scheme, port, or path.
        trusted_proxy_cidrs:
          type: array
          maxItems: 64
          items:
            type: string
            description: Canonical IPv4 or IPv6 CIDR whose direct connections may supply forwarding headers.
        local_signup_enabled: { type: boolean }
        draft_storage_mode: { $ref: "#/components/schemas/DraftPolicy/properties/storage_mode" }
        draft_retention_days: { type: integer, minimum: 1, maximum: 30, default: 7 }
        draft_clear_on_logout: { type: boolean, default: true }
    OperationalState:
      type: string
      enum: [ready, warning, unknown]
    DatabasePoolStatus:
      type: object
      additionalProperties: false
      required: [acquired, idle, total, max]
      properties:
        acquired: { type: integer, format: int32, minimum: 0 }
        idle: { type: integer, format: int32, minimum: 0 }
        total: { type: integer, format: int32, minimum: 0 }
        max: { type: integer, format: int32, minimum: 0 }
    DatabaseMigrationStatus:
      type: object
      additionalProperties: false
      required: [applied_version, applied_name, applied_at, target_version, target_name]
      properties:
        applied_version: { type: integer, format: int64, minimum: 0 }
        applied_name: { type: string }
        applied_at: { type: integer, format: int64, minimum: 0 }
        target_version: { type: integer, format: int64, minimum: 0 }
        target_name: { type: string }
    DatabaseOperationalStatus:
      type: object
      additionalProperties: false
      required: [state, message, migration, pool]
      properties:
        state: { $ref: "#/components/schemas/OperationalState" }
        message: { type: string }
        migration: { $ref: "#/components/schemas/DatabaseMigrationStatus" }
        pool: { $ref: "#/components/schemas/DatabasePoolStatus" }
    ScheduledQueueStatus:
      type: object
      additionalProperties: false
      required: [pending, processing, retry, dead, due, expired_leases]
      properties:
        pending: { type: integer, format: int64, minimum: 0 }
        processing: { type: integer, format: int64, minimum: 0 }
        retry: { type: integer, format: int64, minimum: 0 }
        dead: { type: integer, format: int64, minimum: 0 }
        due: { type: integer, format: int64, minimum: 0 }
        expired_leases: { type: integer, format: int64, minimum: 0 }
    ReminderQueueStatus:
      type: object
      additionalProperties: false
      required: [pending, processing, due]
      properties:
        pending: { type: integer, format: int64, minimum: 0 }
        processing: { type: integer, format: int64, minimum: 0 }
        due: { type: integer, format: int64, minimum: 0 }
    ApprovalQueueStatus:
      type: object
      additionalProperties: false
      required: [pending, processing, failed]
      properties:
        pending: { type: integer, format: int64, minimum: 0 }
        processing: { type: integer, format: int64, minimum: 0 }
        failed: { type: integer, format: int64, minimum: 0 }
    WorkerOperationalStatus:
      type: object
      additionalProperties: false
      required: [state, message, runtime_observable, scheduled, reminders, approvals]
      properties:
        state: { $ref: "#/components/schemas/OperationalState" }
        message: { type: string }
        runtime_observable:
          type: boolean
          description: False until a worker heartbeat proves that a worker is executing.
        scheduled: { $ref: "#/components/schemas/ScheduledQueueStatus" }
        reminders: { $ref: "#/components/schemas/ReminderQueueStatus" }
        approvals: { $ref: "#/components/schemas/ApprovalQueueStatus" }
    WebhookOperationalStatus:
      type: object
      additionalProperties: false
      required:
        - state
        - message
        - runtime_observable
        - pending
        - processing
        - retry
        - succeeded
        - dead
        - expired_leases
        - last_succeeded_at
        - last_dead_at
      properties:
        state: { $ref: "#/components/schemas/OperationalState" }
        message: { type: string }
        runtime_observable:
          type: boolean
          description: False until a dispatcher heartbeat proves that delivery is executing.
        pending: { type: integer, format: int64, minimum: 0 }
        processing: { type: integer, format: int64, minimum: 0 }
        retry: { type: integer, format: int64, minimum: 0 }
        succeeded: { type: integer, format: int64, minimum: 0 }
        dead: { type: integer, format: int64, minimum: 0 }
        expired_leases: { type: integer, format: int64, minimum: 0 }
        last_succeeded_at: { type: integer, format: int64, minimum: 0 }
        last_dead_at: { type: integer, format: int64, minimum: 0 }
    StorageOperationalStatus:
      type: object
      additionalProperties: false
      required: [state, message, configured_backend, active_backend, fallback, file_count, bytes]
      properties:
        state: { $ref: "#/components/schemas/OperationalState" }
        message: { type: string }
        configured_backend: { type: string }
        active_backend: { type: string }
        fallback: { type: boolean }
        file_count: { type: integer, format: int64, minimum: 0 }
        bytes: { type: integer, format: int64, minimum: 0 }
    AdminOperationsSnapshot:
      type: object
      additionalProperties: false
      required: [checked_at, database, workers, webhooks, storage]
      properties:
        checked_at: { type: integer, format: int64, minimum: 0 }
        database: { $ref: "#/components/schemas/DatabaseOperationalStatus" }
        workers: { $ref: "#/components/schemas/WorkerOperationalStatus" }
        webhooks: { $ref: "#/components/schemas/WebhookOperationalStatus" }
        storage: { $ref: "#/components/schemas/StorageOperationalStatus" }
    Team:
      type: object
      required: [id, name, display_name, type, create_at, update_at, delete_at]
      properties:
        id: { type: string }
        name: { type: string }
        display_name: { type: string }
        type: { type: string, enum: [O, I] }
        create_at: { type: integer, format: int64, minimum: 0 }
        update_at: { type: integer, format: int64, minimum: 0 }
        delete_at: { type: integer, format: int64, minimum: 0 }
    Channel:
      type: object
      required: [id, team_id, type, display_name, name, header, purpose, create_at, update_at, delete_at]
      properties:
        id: { type: string }
        team_id: { type: string }
        type: { type: string, enum: [O, P, D, G] }
        display_name: { type: string }
        name: { type: string }
        header: { type: string }
        purpose: { type: string }
        create_at: { type: integer, format: int64, minimum: 0 }
        update_at: { type: integer, format: int64, minimum: 0 }
        delete_at: { type: integer, format: int64, minimum: 0 }
    ChannelMembership:
      type: object
      required: [channel_id, user_id, roles, last_viewed_at, msg_count, mention_count, notify_props]
      properties:
        channel_id: { type: string }
        user_id: { type: string }
        roles: { type: string }
        last_viewed_at: { type: integer, format: int64, minimum: 0 }
        msg_count: { type: integer, format: int64, minimum: 0 }
        mention_count: { type: integer, format: int64, minimum: 0 }
        notify_props: { type: object, additionalProperties: true }
    FlowCounts:
      type: object
      required: [unread_channels, mentions]
      properties:
        unread_channels: { type: integer, format: int64, minimum: 0 }
        mentions: { type: integer, format: int64, minimum: 0 }
    FlowUnreadChannel:
      type: object
      required: [team_id, channel_id, msg_count, mention_count, last_viewed_at]
      properties:
        team_id: { type: string }
        channel_id: { type: string }
        msg_count: { type: integer, format: int64, minimum: 0 }
        mention_count: { type: integer, format: int64, minimum: 0 }
        last_viewed_at: { type: integer, format: int64, minimum: 0 }
    FlowSummary:
      type: object
      required: [updated_at, counts, teams, channels, memberships, top_unread_channels]
      properties:
        updated_at: { type: integer, format: int64, minimum: 1 }
        counts: { $ref: "#/components/schemas/FlowCounts" }
        teams:
          type: array
          items: { $ref: "#/components/schemas/Team" }
        channels:
          type: array
          items: { $ref: "#/components/schemas/Channel" }
        memberships:
          type: array
          items: { $ref: "#/components/schemas/ChannelMembership" }
        top_unread_channels:
          type: array
          maxItems: 6
          items: { $ref: "#/components/schemas/FlowUnreadChannel" }
    ActivityEventType:
      type: string
      enum:
        - mention
        - thread_reply
        - direct_message
        - approval_requested
        - decided
        - reminder_fired
        - task_assigned
        - system_warning
        - plugin_event
    ActivityEvent:
      type: object
      additionalProperties: false
      required:
        - id
        - type
        - title
        - create_at
        - update_at
        - read_at
        - completed_at
        - snoozed_until
      properties:
        id: { type: string }
        type: { $ref: "#/components/schemas/ActivityEventType" }
        actor_id: { type: string }
        team_id: { type: string }
        channel_id: { type: string }
        post_id: { type: string }
        resource_type: { type: string }
        resource_id: { type: string }
        title: { type: string, minLength: 1, maxLength: 256 }
        summary: { type: string, maxLength: 4096 }
        create_at: { type: integer, format: int64, minimum: 1 }
        update_at: { type: integer, format: int64, minimum: 1 }
        read_at: { type: integer, format: int64, minimum: 0 }
        completed_at: { type: integer, format: int64, minimum: 0 }
        snoozed_until: { type: integer, format: int64, minimum: 0 }
    ActivityEventPage:
      type: object
      additionalProperties: false
      required: [events, next_cursor]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/ActivityEvent" }
        next_cursor: { type: string }
    ActivityStatePatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        read: { type: boolean }
        completed: { type: boolean }
        snoozed_until: { type: integer, format: int64, minimum: 0 }
    ActivityMarkReadRequest:
      type: object
      additionalProperties: false
      required: [ids]
      properties:
        ids:
          type: array
          minItems: 1
          maxItems: 200
          items: { type: string, minLength: 1, maxLength: 128 }
    ActivityMarkReadResult:
      type: object
      additionalProperties: false
      required: [updated]
      properties:
        updated: { type: integer, format: int64, minimum: 0 }
    WorkItemKind:
      type: string
      enum: [task, decision]
    WorkItemStatus:
      type: string
      enum: [open, in_progress, done, cancelled, proposed, under_review, recorded, superseded]
    WorkItemPriority:
      type: string
      enum: [low, normal, high, urgent]
    WorkItemRecurrence:
      type: string
      enum: [none, daily, weekly, monthly]
    WorkItem:
      type: object
      additionalProperties: false
      required:
        - id
        - kind
        - title
        - description
        - status
        - created_by
        - channel_id
        - due_at
        - decided_at
        - priority
        - completed_at
        - recurrence_unit
        - recurrence_interval
        - occurrence_no
        - dependency_ids
        - impact_task_ids
        - create_at
        - update_at
        - delete_at
      properties:
        id: { type: string, minLength: 1, maxLength: 128 }
        kind: { $ref: "#/components/schemas/WorkItemKind" }
        title: { type: string, minLength: 1, maxLength: 240 }
        description: { type: string, maxLength: 10000 }
        status: { $ref: "#/components/schemas/WorkItemStatus" }
        created_by: { type: string }
        assignee_id: { type: string }
        team_id: { type: string }
        channel_id: { type: string }
        source_post_id: { type: string }
        source_thread_id: { type: string }
        due_at: { type: integer, format: int64, minimum: 0 }
        decided_at: { type: integer, format: int64, minimum: 0 }
        priority: { $ref: "#/components/schemas/WorkItemPriority" }
        completed_at: { type: integer, format: int64, minimum: 0 }
        reviewer_id: { type: string }
        recurrence_unit: { $ref: "#/components/schemas/WorkItemRecurrence" }
        recurrence_interval: { type: integer, minimum: 0, maximum: 365 }
        series_id: { type: string }
        occurrence_no: { type: integer, minimum: 0 }
        supersedes_id: { type: string }
        superseded_by_id: { type: string }
        dependency_ids:
          type: array
          items: { type: string }
        impact_task_ids:
          type: array
          items: { type: string }
        create_at: { type: integer, format: int64, minimum: 1 }
        update_at: { type: integer, format: int64, minimum: 1 }
        delete_at: { type: integer, format: int64, minimum: 0 }
    WorkItemPage:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/WorkItem" }
        next_cursor: { type: string }
    CreateWorkItem:
      type: object
      additionalProperties: false
      required: [kind, title, source_post_id]
      properties:
        kind: { $ref: "#/components/schemas/WorkItemKind" }
        title: { type: string, minLength: 1, maxLength: 240 }
        description: { type: string, maxLength: 10000, default: "" }
        assignee_id:
          type: string
          maxLength: 128
          description: Task only; an empty value defaults to the caller.
        source_post_id: { type: string, minLength: 1, maxLength: 128 }
        due_at:
          type: integer
          format: int64
          minimum: 0
          default: 0
          description: Task only; milliseconds since Unix epoch, or zero for no due date.
        priority: { $ref: "#/components/schemas/WorkItemPriority" }
        reviewer_id:
          type: string
          description: Decision reviewer; must be an active source-channel member.
        initial_status:
          type: string
          enum: [proposed, recorded]
        recurrence_unit: { $ref: "#/components/schemas/WorkItemRecurrence" }
        recurrence_interval: { type: integer, minimum: 0, maximum: 365 }
        supersedes_id: { type: string, maxLength: 128 }
        dependency_ids:
          type: array
          items: { type: string, maxLength: 128 }
        impact_task_ids:
          type: array
          items: { type: string, maxLength: 128 }
        idempotency_key:
          type: string
          minLength: 1
          maxLength: 200
          description: Required when the Idempotency-Key header is omitted.
    CreateWorkItemResult:
      type: object
      additionalProperties: false
      required: [item, replayed]
      properties:
        item: { $ref: "#/components/schemas/WorkItem" }
        replayed: { type: boolean }
    PatchWorkItem:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        title: { type: string, minLength: 1, maxLength: 240 }
        description: { type: string, maxLength: 10000 }
        status: { $ref: "#/components/schemas/WorkItemStatus" }
        assignee_id:
          type: string
          maxLength: 128
          description: Task creator only; empty string removes the assignee.
        due_at: { type: integer, format: int64, minimum: 0 }
        priority: { $ref: "#/components/schemas/WorkItemPriority" }
        reviewer_id: { type: string, maxLength: 128 }
        recurrence_unit: { $ref: "#/components/schemas/WorkItemRecurrence" }
        recurrence_interval: { type: integer, minimum: 0, maximum: 365 }
    WorkItemEvent:
      type: object
      additionalProperties: false
      required: [id, work_item_id, event_type, details, create_at]
      properties:
        id: { type: string }
        work_item_id: { type: string }
        actor_id: { type: string }
        event_type:
          type: string
          enum:
            - created
            - updated
            - status_changed
            - assigned
            - reviewer_changed
            - dependency_added
            - dependency_removed
            - impact_added
            - impact_removed
            - superseded
            - recurrence_spawned
        from_status: { $ref: "#/components/schemas/WorkItemStatus" }
        to_status: { $ref: "#/components/schemas/WorkItemStatus" }
        details: { type: object, additionalProperties: true }
        create_at: { type: integer, format: int64, minimum: 1 }
    AutomationMatchType:
      type: string
      enum: [contains, starts_with]
    AutomationActionType:
      type: string
      enum: [task, decision, reminder]
    AutomationActionConfig:
      type: object
      additionalProperties: false
      properties:
        title: { type: string, maxLength: 240 }
        description: { type: string, maxLength: 10000 }
        assignee_id: { type: string, maxLength: 128 }
        due_offset_minutes: { type: integer, minimum: 0, maximum: 527040 }
        priority: { $ref: "#/components/schemas/WorkItemPriority" }
        recurrence_unit: { $ref: "#/components/schemas/WorkItemRecurrence" }
        recurrence_interval: { type: integer, minimum: 0, maximum: 365 }
        initial_status: { type: string, enum: [proposed, recorded] }
        reviewer_id: { type: string, maxLength: 128 }
        remind_offset_minutes: { type: integer, minimum: 1, maximum: 527040 }
    AutomationAction:
      type: object
      additionalProperties: false
      required: [type, config]
      properties:
        id: { type: string, maxLength: 128 }
        position: { type: integer, minimum: 0, maximum: 4 }
        type: { $ref: "#/components/schemas/AutomationActionType" }
        config: { $ref: "#/components/schemas/AutomationActionConfig" }
    SaveAutomationRule:
      type: object
      additionalProperties: false
      required: [name, channel_id, enabled, match_type, match_value, actions]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        channel_id: { type: string, minLength: 1, maxLength: 128 }
        enabled: { type: boolean }
        match_type: { $ref: "#/components/schemas/AutomationMatchType" }
        match_value: { type: string, minLength: 1, maxLength: 200 }
        actions:
          type: array
          minItems: 1
          maxItems: 5
          items: { $ref: "#/components/schemas/AutomationAction" }
    UpdateAutomationRule:
      type: object
      additionalProperties: false
      required: [name, channel_id, enabled, match_type, match_value, revision, actions]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        channel_id: { type: string, minLength: 1, maxLength: 128 }
        enabled: { type: boolean }
        match_type: { $ref: "#/components/schemas/AutomationMatchType" }
        match_value: { type: string, minLength: 1, maxLength: 200 }
        revision:
          type: integer
          format: int64
          minimum: 1
          description: Exact current rule revision used for optimistic concurrency control.
        actions:
          type: array
          minItems: 1
          maxItems: 5
          items: { $ref: "#/components/schemas/AutomationAction" }
    AutomationRule:
      type: object
      additionalProperties: false
      required:
        - id
        - name
        - created_by
        - channel_id
        - enabled
        - match_type
        - match_value
        - revision
        - actions
        - create_at
        - update_at
        - delete_at
      properties:
        id: { type: string }
        name: { type: string, minLength: 1, maxLength: 120 }
        created_by: { type: string }
        team_id: { type: string }
        channel_id: { type: string, minLength: 1, maxLength: 128 }
        enabled: { type: boolean }
        match_type: { $ref: "#/components/schemas/AutomationMatchType" }
        match_value: { type: string, minLength: 1, maxLength: 200 }
        revision: { type: integer, format: int64, minimum: 1 }
        actions:
          type: array
          minItems: 1
          maxItems: 5
          items: { $ref: "#/components/schemas/AutomationAction" }
        create_at: { type: integer, format: int64, minimum: 1 }
        update_at: { type: integer, format: int64, minimum: 1 }
        delete_at: { type: integer, format: int64, minimum: 0 }
    AutomationRun:
      type: object
      additionalProperties: false
      required:
        - id
        - rule_id
        - action_id
        - post_id
        - actor_id
        - action_type
        - status
        - attempt_count
        - next_attempt_at
        - create_at
        - update_at
      properties:
        id: { type: string }
        rule_id: { type: string }
        action_id: { type: string }
        post_id: { type: string }
        actor_id: { type: string }
        action_type: { $ref: "#/components/schemas/AutomationActionType" }
        status: { type: string, enum: [pending, processing, retry, succeeded, dead, cancelled] }
        attempt_count: { type: integer, minimum: 0 }
        next_attempt_at: { type: integer, format: int64, minimum: 0 }
        claimed_at: { type: integer, format: int64, minimum: 0 }
        lease_until: { type: integer, format: int64, minimum: 0 }
        result_type: { type: string }
        result_id: { type: string }
        last_error_code: { type: string }
        last_error_text: { type: string, maxLength: 1000 }
        create_at: { type: integer, format: int64, minimum: 1 }
        update_at: { type: integer, format: int64, minimum: 1 }
        completed_at: { type: integer, format: int64, minimum: 0 }
    KnowledgeSearchInput:
      type: object
      additionalProperties: false
      required: [query, team_id]
      properties:
        query: { type: string, minLength: 1, maxLength: 500 }
        team_id: { type: string, minLength: 1, maxLength: 128 }
        channel_id: { type: string, minLength: 1, maxLength: 128 }
        limit: { type: integer, minimum: 1, maximum: 50, default: 20 }
    KnowledgeSource:
      type: object
      additionalProperties: false
      required:
        - ref
        - kind
        - id
        - channel_id
        - excerpt
        - author_id
        - author_name
        - create_at
        - update_at
        - rank
      properties:
        ref: { type: string, pattern: "^[MD][1-9][0-9]*$" }
        kind: { type: string, enum: [message, document] }
        id: { type: string }
        team_id: { type: string }
        channel_id: { type: string }
        post_id: { type: string }
        document_id: { type: string }
        source_thread_id: { type: string }
        title: { type: string }
        excerpt: { type: string, maxLength: 1201 }
        author_id: { type: string }
        author_name: { type: string }
        create_at: { type: integer, format: int64, minimum: 1 }
        update_at: { type: integer, format: int64, minimum: 0 }
        rank: { type: number, format: double }
    KnowledgeSearchResult:
      type: object
      additionalProperties: false
      required: [sources, total_hits]
      properties:
        sources:
          type: array
          maxItems: 50
          items: { $ref: "#/components/schemas/KnowledgeSource" }
        total_hits: { type: integer, minimum: 0 }
    Document:
      type: object
      additionalProperties: false
      required:
        - id
        - title
        - body
        - created_by
        - channel_id
        - source_thread_id
        - source_cursor_at
        - revision
        - create_at
        - update_at
        - delete_at
        - stale
      properties:
        id: { type: string }
        title: { type: string, minLength: 1, maxLength: 240 }
        body: { type: string, minLength: 1, maxLength: 100000 }
        created_by: { type: string }
        team_id: { type: string }
        channel_id: { type: string }
        source_thread_id: { type: string }
        source_cursor_at:
          type: integer
          format: int64
          minimum: 1
          description: Opaque content revision; it is not a timestamp.
        revision: { type: integer, format: int64, minimum: 1 }
        create_at: { type: integer, format: int64, minimum: 1 }
        update_at: { type: integer, format: int64, minimum: 1 }
        delete_at: { type: integer, format: int64, minimum: 0 }
        stale: { type: boolean }
    DocumentSourcePost:
      type: object
      additionalProperties: false
      required: [id, channel_id, user_id, username, root_id, message, create_at, update_at]
      properties:
        id: { type: string }
        channel_id: { type: string }
        user_id: { type: string }
        username: { type: string }
        root_id: { type: string }
        message: { type: string }
        create_at: { type: integer, format: int64, minimum: 1 }
        update_at: { type: integer, format: int64, minimum: 0 }
    DocumentSource:
      type: object
      additionalProperties: false
      required: [channel_id, thread_id, cursor_at, posts]
      properties:
        team_id: { type: string }
        channel_id: { type: string }
        thread_id: { type: string }
        cursor_at:
          type: integer
          format: int64
          minimum: 1
          description: Opaque content revision; it is not a timestamp.
        posts:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: "#/components/schemas/DocumentSourcePost" }
    CreateDocument:
      type: object
      additionalProperties: false
      required: [title, body, source_post_id, source_cursor_at]
      properties:
        title: { type: string, minLength: 1, maxLength: 240 }
        body: { type: string, minLength: 1, maxLength: 100000 }
        source_post_id: { type: string, minLength: 1, maxLength: 128 }
        source_cursor_at: { type: integer, format: int64, minimum: 1 }
        idempotency_key:
          type: string
          minLength: 1
          maxLength: 200
          description: Required when the Idempotency-Key header is omitted.
    CreateDocumentResult:
      type: object
      additionalProperties: false
      required: [document, replayed]
      properties:
        document: { $ref: "#/components/schemas/Document" }
        replayed: { type: boolean }
    PatchDocument:
      type: object
      additionalProperties: false
      required: [expected_revision]
      anyOf:
        - required: [title]
        - required: [body]
        - required: [source_cursor_at]
      properties:
        title: { type: string, minLength: 1, maxLength: 240 }
        body: { type: string, minLength: 1, maxLength: 100000 }
        source_cursor_at: { type: integer, format: int64, minimum: 1 }
        expected_revision: { type: integer, format: int64, minimum: 1 }
    InboxPreferencesFields:
      type: object
      properties:
        vip_user_ids:
          type: array
          maxItems: 200
          uniqueItems: true
          items: { type: string, minLength: 1, maxLength: 128 }
        priority_event_types:
          type: array
          maxItems: 16
          uniqueItems: true
          items: { $ref: "#/components/schemas/ActivityEventType" }
        bundle_by: { type: string, enum: [none, channel, type] }
        snooze_presets_minutes:
          type: array
          minItems: 1
          maxItems: 8
          uniqueItems: true
          items: { type: integer, minimum: 5, maximum: 43200 }
        work_hours_enabled: { type: boolean }
        work_hours_timezone: { type: string, minLength: 1, maxLength: 128 }
        work_hours_weekdays:
          type: array
          minItems: 1
          maxItems: 7
          uniqueItems: true
          items: { type: integer, minimum: 1, maximum: 7 }
        work_hours_start_minute: { type: integer, minimum: 0, maximum: 1439 }
        work_hours_end_minute: { type: integer, minimum: 0, maximum: 1439 }
        priority_override: { type: boolean }
    InboxPreferences:
      unevaluatedProperties: false
      allOf:
        - $ref: "#/components/schemas/InboxPreferencesFields"
        - type: object
          required:
            - vip_user_ids
            - priority_event_types
            - bundle_by
            - snooze_presets_minutes
            - work_hours_enabled
            - work_hours_timezone
            - work_hours_weekdays
            - work_hours_start_minute
            - work_hours_end_minute
            - priority_override
            - update_at
          properties:
            update_at: { type: integer, format: int64, minimum: 0 }
    PatchInboxPreferences:
      unevaluatedProperties: false
      minProperties: 1
      allOf:
        - $ref: "#/components/schemas/InboxPreferencesFields"
    OIDCOnboardingChannelTarget:
      type: object
      additionalProperties: false
      required: [id, team_id, name, display_name, type]
      properties:
        id: { type: string }
        team_id: { type: string }
        name: { type: string }
        display_name: { type: string }
        type: { type: string, enum: [O, P] }
    OIDCOnboardingTeamTarget:
      type: object
      additionalProperties: false
      required: [id, name, display_name, channels]
      properties:
        id: { type: string }
        name: { type: string }
        display_name: { type: string }
        channels:
          type: array
          items: { $ref: "#/components/schemas/OIDCOnboardingChannelTarget" }
    OIDCOnboardingTargets:
      type: object
      additionalProperties: false
      required: [teams]
      properties:
        teams:
          type: array
          items: { $ref: "#/components/schemas/OIDCOnboardingTeamTarget" }
    ApprovalAction:
      type: string
      enum: [mcp.create_post, mcp.reply_to_thread]
    ApprovalPreviewActor:
      type: object
      additionalProperties: false
      required: [type, display_name]
      properties:
        type: { type: string, enum: [mcp_key, automation] }
        display_name: { type: string }
    ApprovalPreviewTarget:
      type: object
      additionalProperties: false
      required: [type, display_name]
      properties:
        type: { type: string, enum: [channel, team, resource] }
        display_name: { type: string }
    ApprovalPreviewChange:
      type: object
      additionalProperties: false
      required: [label, after]
      properties:
        label: { type: string }
        after:
          type: string
          maxLength: 4000
          description: Server-redacted and bounded display text.
    ApprovalPreviewPolicy:
      type: object
      additionalProperties: false
      required: [name, reason]
      properties:
        name: { type: string }
        reason: { type: string }
    ApprovalPreview:
      type: object
      additionalProperties: false
      required: [title, risk_level, actor, target, changes, policy, secrets_redacted]
      properties:
        title: { type: string }
        risk_level: { type: string, enum: [medium, unknown] }
        actor: { $ref: "#/components/schemas/ApprovalPreviewActor" }
        target: { $ref: "#/components/schemas/ApprovalPreviewTarget" }
        changes:
          type: array
          items: { $ref: "#/components/schemas/ApprovalPreviewChange" }
        policy: { $ref: "#/components/schemas/ApprovalPreviewPolicy" }
        secrets_redacted:
          type: boolean
          const: true
          description: Raw durable payload is never part of the view.
    ApprovalRequestView:
      type: object
      additionalProperties: false
      required:
        - id
        - policy_id
        - action_type
        - requester_id
        - team_id
        - resource_type
        - resource_id
        - status
        - create_at
        - update_at
        - decided_at
        - executed_at
        - expires_at
        - preview
      properties:
        id: { type: string }
        policy_id: { type: string }
        action_type: { $ref: "#/components/schemas/ApprovalAction" }
        requester_id: { type: string }
        team_id: { type: string }
        resource_type: { type: string, enum: [channel, team] }
        resource_id: { type: string }
        status: { type: string, enum: [pending, approved, rejected, expired, executed] }
        idempotency_key: { type: string }
        create_at: { type: integer, format: int64, minimum: 0 }
        update_at: { type: integer, format: int64, minimum: 0 }
        decided_at: { type: integer, format: int64, minimum: 0 }
        executed_at: { type: integer, format: int64, minimum: 0 }
        expires_at: { type: integer, format: int64, minimum: 0 }
        preview: { $ref: "#/components/schemas/ApprovalPreview" }
    SubmitApprovalRequest:
      type: object
      required: [action_type, resource_type, resource_id, payload]
      properties:
        action_type: { $ref: "#/components/schemas/ApprovalAction" }
        team_id: { type: string }
        resource_type: { type: string, const: channel }
        resource_id: { type: string, minLength: 1 }
        payload:
          type: object
          required: [channel_id, message]
          properties:
            channel_id: { type: string, minLength: 1 }
            message: { type: string, minLength: 1 }
            root_id:
              type: string
              description: Required and source-channel validated for mcp.reply_to_thread.
          additionalProperties: true
        idempotency_key: { type: string }
    ApprovalResultView:
      type: object
      additionalProperties: false
      required: [approval_required]
      properties:
        approval_required: { type: boolean }
        request: { $ref: "#/components/schemas/ApprovalRequestView" }
    ApprovalDecisionInput:
      type: object
      required: [decision]
      properties:
        decision: { type: string, enum: [approve, reject] }
        reason:
          type: string
          description: Required for rejection when the matching policy requires it.
