openapi: "3.1.0"
info:
  title: Raven API (stub for frontend development)
  description: |
    Stub contract used by the frontend stream during parallel development.
    Reflects the real API shape that the backend stream will implement.
    Replace with real OpenAPI spec once backend PRs merge.
  version: "1.0.0"

servers:
  - url: /api/v1
    description: Local dev

tags:
  - name: Organizations
    description: Organization lifecycle and membership.
  - name: Workspaces
    description: Workspace lifecycle and membership inside an organization.
  - name: KnowledgeBases
    description: Knowledge base lifecycle inside a workspace.
  - name: Users
    description: Authenticated user profile endpoints.
  - name: Search
    description: Full-text and hybrid retrieval over knowledge-base chunks.
  - name: Marketplace
    description: |
      Cross-tenant discovery surface for Public KBs. Authenticated User
      endpoints for listing, previewing, importing, publishing, and
      unpublishing. See ADR-0001, ADR-0002, ADR-0007.
  - name: Marketplace Admin
    description: |
      Admin-only moderation surface: report queue, takedown actions, and
      the DMCA counter-notice intake. See ADR-0006.

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Organization:
      type: object
      properties:
        id:        { type: string, format: uuid }
        name:      { type: string }
        slug:      { type: string }
        status:    { type: string, enum: [active, deactivated] }
        settings:  { type: object }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      required: [id, name, slug, status, created_at, updated_at]

    CreateOrgRequest:
      type: object
      properties:
        name: { type: string, minLength: 2, maxLength: 255 }
      required: [name]

    UpdateOrgRequest:
      type: object
      properties:
        name:     { type: string, minLength: 2, maxLength: 255 }
        settings: { type: object }

    OrgMember:
      type: object
      properties:
        user_id:  { type: string, format: uuid }
        email:    { type: string, format: email }
        org_role: { type: string, enum: [org_admin, member] }
        joined_at: { type: string, format: date-time }
      required: [user_id, email, org_role]

    Workspace:
      type: object
      properties:
        id:        { type: string, format: uuid }
        org_id:    { type: string, format: uuid }
        name:      { type: string }
        slug:      { type: string }
        settings:  { type: object }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      required: [id, org_id, name, slug, created_at, updated_at]

    CreateWorkspaceRequest:
      type: object
      properties:
        name: { type: string, minLength: 2, maxLength: 255 }
      required: [name]

    WorkspaceMember:
      type: object
      properties:
        user_id:        { type: string, format: uuid }
        email:          { type: string, format: email }
        workspace_role: { type: string, enum: [owner, admin, member, viewer] }
      required: [user_id, email, workspace_role]

    KnowledgeBase:
      type: object
      properties:
        id:           { type: string, format: uuid }
        org_id:       { type: string, format: uuid }
        workspace_id: { type: string, format: uuid }
        name:         { type: string }
        slug:         { type: string }
        settings:     { type: object }
        status:       { type: string, enum: [active, archived] }
        doc_count:    { type: integer }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time }
      required: [id, org_id, workspace_id, name, slug, status, created_at, updated_at]

    User:
      type: object
      properties:
        id:       { type: string, format: uuid }
        email:    { type: string, format: email }
        username: { type: string }
        org_id:   { type: string, format: uuid }
        org_role: { type: string, enum: [org_admin, member] }
      required: [id, email, org_id, org_role]

    ErrorResponse:
      type: object
      properties:
        code:    { type: integer }
        message: { type: string }
        detail:  { type: string }
      required: [code, message]

    PaginatedList:
      type: object
      properties:
        items:  { type: array, items: {} }
        total:  { type: integer }
        offset: { type: integer }
        limit:  { type: integer }
      required: [items, total]

    HybridSearchRequest:
      type: object
      description: |
        Request body for hybrid (vector + BM25) search. `embedding` is the
        pre-computed query embedding for the vector leg; omit it to fall
        back to BM25-only ranking.
      properties:
        query:
          type: string
          minLength: 1
          description: The user query. Required and non-empty.
        top_k:
          type: integer
          minimum: 0
          description: |
            Maximum number of fused results to return. `0` or omitted means
            the server applies `retrieval.default_limit`. Values above
            `retrieval.max_limit` are clamped server-side (no 400).
        filters:
          type: object
          additionalProperties: { type: string }
          description: |
            Free-form metadata filter bag. Today only honoured downstream
            (e.g. `rerank: cohere` on the chat path); the standalone
            endpoint does not yet push predicates into SQL.
        doc_ids:
          type: array
          items: { type: string, format: uuid }
          description: Restrict search to these document IDs.
        embedding:
          type: array
          items: { type: number, format: float }
          description: |
            Pre-computed query embedding. Length must match the configured
            embedding dimension (1536 by default). Empty array disables
            the vector leg — RRF degrades to BM25-only.
      required: [query]

    HybridSearchResult:
      type: object
      description: |
        Single chunk returned from a hybrid search, carrying both per-leg
        scores and the fused Reciprocal Rank Fusion (RRF) score.
      properties:
        chunk_id:          { type: string, format: uuid }
        org_id:            { type: string, format: uuid }
        knowledge_base_id: { type: string, format: uuid }
        document_id:       { type: string, format: uuid }
        source_id:         { type: string, format: uuid }
        content:           { type: string }
        chunk_index:       { type: integer }
        token_count:       { type: integer }
        page_number:       { type: integer }
        heading:           { type: string }
        chunk_type:        { type: string }
        created_at:        { type: string, format: date-time }
        vector_score:      { type: number, format: float, description: "Cosine similarity in [0, 1]" }
        bm25_score:        { type: number, format: float, description: "PostgreSQL ts_rank_cd score" }
        rrf_score:         { type: number, format: float, description: "Fused RRF score" }
        vector_rank:       { type: integer, description: "1-based rank in the vector leg; 0 if absent" }
        bm25_rank:         { type: integer, description: "1-based rank in the BM25 leg; 0 if absent" }
      required: [chunk_id, org_id, knowledge_base_id, content, chunk_index, chunk_type, created_at, rrf_score]

    HybridSearchResponse:
      type: object
      properties:
        results:
          type: array
          items: { $ref: '#/components/schemas/HybridSearchResult' }
        query:
          type: string
          description: Server-side trimmed echo of the requested query.
        top_k:
          type: integer
          description: Effective top_k after server-side clamping.
      required: [results, query, top_k]

    MarketplaceListItem:
      type: object
      description: |
        Card-shape row for the Marketplace listing. Returned by the
        `marketplace_list_public_kbs()` SECURITY DEFINER function and any
        per-User filtering applied above it. `source_*` fields are
        populated when this Public KB was itself imported from another
        Public KB (one-hop lineage; see ADR-0001).
      properties:
        kb_id:                     { type: string, format: uuid }
        org_slug:                  { type: string }
        org_display_name:          { type: string }
        kb_slug:                   { type: string }
        name:                      { type: string }
        description:               { type: string }
        last_modified_at:          { type: string, format: date-time }
        license_spdx_id:           { type: string, description: "SPDX id from the 7-item allow-list (ADR-0006)." }
        import_count:              { type: integer, minimum: 0 }
        source_public_kb_id:       { type: string, format: uuid, nullable: true }
        source_org_slug:           { type: string, nullable: true }
        source_org_display_name:   { type: string, nullable: true }
      required: [kb_id, org_slug, org_display_name, kb_slug, name, last_modified_at, license_spdx_id, import_count]

    MarketplaceListing:
      type: object
      description: |
        Paginated Marketplace listing response. `next_cursor` is opaque;
        callers must treat it as a black-box string to be echoed back.
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/MarketplaceListItem' }
        next_cursor:
          type: string
          nullable: true
        total:
          type: integer
          description: Total matching rows when cheaply computable; omitted otherwise.
      required: [items]

    PreviewChunk:
      type: object
      description: |
        Single sample Chunk returned by the Marketplace preview surface
        (ADR-0007). Backed by `marketplace_preview_kb()`; the function
        caps the response at three rows.
      properties:
        chunk_id: { type: string, format: uuid }
        ordinal:  { type: integer, minimum: 0 }
        text:     { type: string }
      required: [chunk_id, ordinal, text]

    MarketplacePreview:
      type: object
      properties:
        chunks:
          type: array
          maxItems: 3
          items: { $ref: '#/components/schemas/PreviewChunk' }
      required: [chunks]

    PublishRequest:
      type: object
      description: |
        Body for `POST /knowledge_bases/{id}/publish`. The license must be
        one of the seven SPDX ids in the allow-list (ADR-0006); requests
        carrying any other value return 422.
      properties:
        license_spdx_id:
          type: string
          description: SPDX id from the 7-item allow-list (e.g. `CC-BY-4.0`).
      required: [license_spdx_id]

    ReImportRequest:
      type: object
      description: |
        Body for `POST /knowledge_bases/{id}/re-import`. The explicit
        `confirm` flag guards against accidental destructive overwrites
        of locally-edited Imported KB content (ADR-0001 fork semantics).
      properties:
        confirm:
          type: boolean
          description: Must be `true` to acknowledge the destructive re-fork.
      required: [confirm]

    ImportRequest:
      type: object
      description: |
        Body for `POST /marketplace/import/{public_kb_id}`. The Importer
        chooses which Workspace inside their Org will own the new KB row;
        Free Plan Orgs have visibility forced to `public` per ADR-0004.
      properties:
        workspace_id:
          type: string
          format: uuid
      required: [workspace_id]

    ImportResult:
      type: object
      properties:
        kb_id:                     { type: string, format: uuid }
        imported_from_revision_at: { type: string, format: date-time }
      required: [kb_id, imported_from_revision_at]

    ReportRequest:
      type: object
      description: |
        Submit a moderation Report against a Public KB. Per-User rate
        limited; abuse returns 429.
      properties:
        kb_id:  { type: string, format: uuid }
        reason: { type: string, minLength: 1, maxLength: 2000 }
      required: [kb_id, reason]

    MarketplaceReport:
      type: object
      properties:
        report_id:        { type: string, format: uuid }
        reported_kb_id:   { type: string, format: uuid }
        reporter_user_id: { type: string, format: uuid, nullable: true }
        reason:           { type: string }
        status:           { type: string, enum: [open, reviewing, resolved, dismissed] }
        created_at:       { type: string, format: date-time }
      required: [report_id, reported_kb_id, reason, status, created_at]

    DMCANotice:
      type: object
      description: |
        DMCA takedown notice intake. Receipt queues the notice for the
        14-day counter-notice window before the target KB is unpublished
        (ADR-0006).
      properties:
        target_kb_id: { type: string, format: uuid }
        notice:       { type: string, minLength: 1, description: "Full text of the DMCA notice as submitted." }
      required: [target_kb_id, notice]

    DMCANoticeReceipt:
      type: object
      properties:
        notice_id:          { type: string, format: uuid }
        target_kb_id:       { type: string, format: uuid }
        counter_notice_due: { type: string, format: date-time, description: "14 days from receipt." }
        status:             { type: string, enum: [queued, counter_noticed, executed, withdrawn] }
      required: [notice_id, target_kb_id, counter_notice_due, status]

paths:
  /orgs:
    post:
      summary: Create organization
      operationId: createOrganization
      tags: [Organizations]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateOrgRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /orgs/{org_id}:
    get:
      summary: Get organization
      operationId: getOrganization
      tags: [Organizations]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '404':
          description: Not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    put:
      summary: Update organization
      operationId: updateOrganization
      tags: [Organizations]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateOrgRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
    delete:
      summary: Soft-delete organization
      operationId: deleteOrganization
      tags: [Organizations]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Deleted

  /orgs/{org_id}/members:
    get:
      summary: List organization members
      operationId: listOrganizationMembers
      tags: [Organizations]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OrgMember' }

  /orgs/{org_id}/workspaces:
    get:
      summary: List workspaces
      operationId: listWorkspaces
      tags: [Workspaces]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginatedList' }
    post:
      summary: Create workspace
      operationId: createWorkspace
      tags: [Workspaces]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateWorkspaceRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Workspace' }

  /orgs/{org_id}/workspaces/{ws_id}:
    get:
      summary: Get workspace
      operationId: getWorkspace
      tags: [Workspaces]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: ws_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Workspace' }
    delete:
      summary: Soft-delete workspace
      operationId: deleteWorkspace
      tags: [Workspaces]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: ws_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Deleted

  /orgs/{org_id}/workspaces/{ws_id}/members:
    post:
      summary: Add workspace member
      operationId: addWorkspaceMember
      tags: [Workspaces]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: ws_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { type: string, format: uuid }
                role:    { type: string, enum: [owner, admin, member, viewer] }
              required: [user_id, role]
      responses:
        '201':
          description: Added

  /orgs/{org_id}/workspaces/{ws_id}/knowledge-bases:
    get:
      summary: List knowledge bases
      operationId: listKnowledgeBases
      tags: [KnowledgeBases]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: ws_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/KnowledgeBase' }
    post:
      summary: Create knowledge base
      operationId: createKnowledgeBase
      tags: [KnowledgeBases]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: ws_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:     { type: string }
                settings: { type: object }
              required: [name]
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }

  /orgs/{org_id}/workspaces/{ws_id}/knowledge-bases/{kb_id}/hybrid-search:
    post:
      summary: Hybrid (vector + BM25) search within a knowledge base
      description: |
        Fuses pgvector cosine similarity with PostgreSQL tsvector BM25
        ranking via Reciprocal Rank Fusion (RRF). The caller may supply a
        pre-computed query embedding for the vector leg; if omitted, the
        response degrades cleanly to BM25-only ranking. `top_k` is clamped
        server-side to `retrieval.max_limit`.
      operationId: hybridSearchKnowledgeBase
      tags: [Search]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: ws_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kb_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HybridSearchRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HybridSearchResponse' }
        '400':
          description: Bad Request — missing/empty query or malformed body
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Forbidden — caller is not a workspace member
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Knowledge base not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /me:
    get:
      summary: Get current user profile
      operationId: getCurrentUser
      tags: [Users]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }

  /marketplace:
    get:
      summary: List Public KBs on the Marketplace
      description: |
        Paginated cross-tenant listing of `visibility='public'` KBs.
        Backed by the `marketplace_list_public_kbs()` SECURITY DEFINER
        function (ADR-0005). Authenticated Users only; anonymous browsing
        is explicitly deferred.
      operationId: listMarketplace
      tags: [Marketplace]
      parameters:
        - name: q
          in: query
          description: Free-text search across `name` and `description`.
          schema: { type: string }
        - name: sort
          in: query
          schema:
            type: string
            enum: [newest, most_imported, recently_updated, alphabetic]
            default: recently_updated
        - name: license
          in: query
          description: Repeatable filter on SPDX id; ORed together.
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
        - name: limit
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarketplaceListing' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /marketplace/{org_slug}/{kb_slug}:
    get:
      summary: Get a Public KB by org_slug and kb_slug
      description: |
        Returns the public-facing KB detail row. The 410 Gone response is
        used (not 404) when the `(org_id, kb_slug)` pair is present in
        `kb_slug_holds` with `held_until > now()` — i.e. the KB was
        unpublished within the 90-day slug-hold window (ADR-0007).
      operationId: getMarketplaceKb
      tags: [Marketplace]
      parameters:
        - name: org_slug
          in: path
          required: true
          schema: { type: string }
        - name: kb_slug
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Unknown org_slug or kb_slug.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '410':
          description: |
            Gone — the slug pair is in `kb_slug_holds`. Distinguishes
            "previously published, now unpublished" from "never existed".
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /marketplace/{org_slug}/{kb_slug}/preview:
    get:
      summary: Preview a Public KB (≤3 sample Chunks)
      description: |
        Returns up to three sample Chunks from the target Public KB. The
        only sanctioned cross-tenant read of Chunk rows in the system;
        backed by the `marketplace_preview_kb()` SECURITY DEFINER function
        which raises `insufficient_privilege` if `visibility != 'public'`
        (ADR-0007).
      operationId: previewMarketplaceKb
      tags: [Marketplace]
      parameters:
        - name: org_slug
          in: path
          required: true
          schema: { type: string }
        - name: kb_slug
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarketplacePreview' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Target KB is not `visibility='public'`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Unknown org_slug or kb_slug.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '410':
          description: Slug is in `kb_slug_holds`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /marketplace/import/{public_kb_id}:
    post:
      summary: Import a Public KB as a content-grade fork
      description: |
        Creates a new Imported KB row in the chosen Workspace. Content is
        copied via the `KB → PublishedKBProjection` projector (ADR-0002);
        original file blobs are not crossed. `source_public_kb_id` and
        `imported_from_revision_at` are set on the new row (ADR-0001).
        Free Plan Orgs have `visibility` forced to `public` (ADR-0004).
      operationId: importMarketplaceKb
      tags: [Marketplace]
      parameters:
        - name: public_kb_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImportRequest' }
      responses:
        '201':
          description: Imported
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportResult' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Caller is not a member of the destination workspace, or target KB is not public.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Unknown `public_kb_id` or `workspace_id`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '422':
          description: Plan-rule violation (e.g. Free Plan attempting to set `visibility='private'`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /knowledge_bases/{id}/publish:
    post:
      summary: Publish a KB to the Marketplace
      description: |
        Flips `visibility` to `public` and sets `published_at`,
        `published_by_user_id`, and `license_spdx_id`. The license must be
        one of the seven SPDX ids in the allow-list (ADR-0006); other
        values return 422. Slug re-use during the 90-day hold window
        returns 409.
      operationId: publishKnowledgeBase
      tags: [Marketplace]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublishRequest' }
      responses:
        '200':
          description: Published
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Plan or permission violation (e.g. Free Plan Org trying to set `visibility='private'`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: KB not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '409':
          description: Slug is held in `kb_slug_holds` for this Org.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '422':
          description: License not in the SPDX allow-list, or KB has no documents.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /knowledge_bases/{id}/unpublish:
    post:
      summary: Unpublish a KB from the Marketplace
      description: |
        Flips `visibility` back to `private`. The `(org_id, kb_slug)` pair
        is registered in `kb_slug_holds` with `held_until = now() + 90d`;
        the Marketplace URL begins returning 410 (ADR-0007). Already-
        Imported forks are unaffected.
      operationId: unpublishKnowledgeBase
      tags: [Marketplace]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Unpublished
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Caller lacks `kb:publish`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: KB not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /knowledge_bases/{id}/re-import:
    post:
      summary: Re-import the source revision into an Imported KB
      description: |
        Re-runs the projection apply against the current state of the
        source Public KB, bumping `imported_from_revision_at`. Destructive
        for any local edits; the `confirm: true` flag is required to
        proceed (ADR-0001). Only callable when `source_public_kb_id IS
        NOT NULL`.
      operationId: reImportKnowledgeBase
      tags: [Marketplace]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReImportRequest' }
      responses:
        '200':
          description: Re-imported
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Caller lacks `kb:write`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: KB not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '409':
          description: KB is not an import (`source_public_kb_id IS NULL`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '410':
          description: Source Public KB was unpublished.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '422':
          description: "`confirm` flag missing or not `true`."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /marketplace/reports:
    post:
      summary: Submit a Marketplace moderation report
      description: |
        Logged-in Users only. Per-User rate-limited; bursts return 429.
      operationId: submitMarketplaceReport
      tags: [Marketplace]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReportRequest' }
      responses:
        '201':
          description: Report queued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarketplaceReport' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Reported KB not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          description: Per-User rate limit exceeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /admin/marketplace/reports:
    get:
      summary: List Marketplace reports (admin)
      description: Admin-only review queue.
      operationId: listMarketplaceReportsAdmin
      tags: [Marketplace Admin]
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [open, reviewing, resolved, dismissed]
            default: open
        - name: limit
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/MarketplaceReport' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Caller is not an admin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /admin/marketplace/reports/{id}/approve:
    post:
      summary: Approve a Marketplace report (admin)
      description: |
        Marks the report `resolved`, unpublishes the target KB (entering
        the 90-day slug-hold), and increments
        `organizations.takedown_strikes` (ADR-0006).
      operationId: approveMarketplaceReportAdmin
      tags: [Marketplace Admin]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Resolved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarketplaceReport' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Caller is not an admin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Report not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /admin/marketplace/reports/{id}/dismiss:
    post:
      summary: Dismiss a Marketplace report (admin)
      description: Marks the report `dismissed` with no further action.
      operationId: dismissMarketplaceReportAdmin
      tags: [Marketplace Admin]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Dismissed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarketplaceReport' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Caller is not an admin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Report not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /admin/marketplace/dmca:
    post:
      summary: File a DMCA takedown notice (admin)
      description: |
        Admin-only intake for DMCA notices. Queues the notice for the
        14-day counter-notice window before the target KB is unpublished
        (ADR-0006).
      operationId: fileMarketplaceDMCANoticeAdmin
      tags: [Marketplace Admin]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DMCANotice' }
      responses:
        '201':
          description: Queued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DMCANoticeReceipt' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Caller is not an admin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Target KB not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
