openapi: 3.1.0
info:
  title: Requiremancer API
  version: 1.0.0
  description: |
    The Requiremancer public REST API lets you programmatically create, traverse, and query
    requirements and test cases, trigger test runs, stream live results, query traceability
    graphs, and integrate outbound webhooks into your own pipelines.

    ## Authentication

    Every endpoint requires authentication. Two methods are accepted simultaneously:

    | Method | Header | Format |
    |--------|--------|--------|
    | API Key | `X-API-Key` | `rm_live_<64-hex-chars>` |
    | JWT Bearer | `Authorization` | `Bearer <token>` |

    Create an API key via `POST /api/v1/api-keys`. The plaintext key is returned exactly once.

    ## Rate Limiting

    1 000 requests per hour per identity. Headers returned on every response:

    - `X-RateLimit-Limit` – total tokens in the window
    - `X-RateLimit-Remaining` – tokens remaining
    - `X-RateLimit-Reset` – Unix epoch when the window resets

    HTTP `429` is returned when the bucket is exhausted, with a `Retry-After` header.

    ## Pagination

    List endpoints use **keyset (cursor) pagination**. Pass `?cursor=<opaque-string>` from the
    previous response `meta.next_cursor` to fetch the next page. The default page size is `50`;
    the maximum is `200`.

    ## Custom Field Filtering

    All entities support `custom_field_filters` — a JSON array of filter objects:

    ```json
    [
      { "key": "asil_level", "operator": "is_any_of", "value": ["B", "C", "D"] },
      { "key": "verified",   "operator": "is_true" },
      { "key": "score",      "operator": "greater_than", "value": 7 }
    ]
    ```

    Supported operators: `equals`, `not_equals`, `contains`, `starts_with`, `is_empty`,
    `is_not_empty`, `is_true`, `is_false`, `greater_than`, `less_than`, `before`, `after`,
    `between`, `is_any_of`, `contains_any`, `contains_all`, `has_value`, `no_value`.

  contact:
    name: Requiremancer
    url: https://requiremancer.com

servers:
  - url: https://api.requiremancer.com/api/v1
    description: Requiremancer cloud
  - url: http://localhost:3030/api/v1
    description: A local or on-premise instance

security:
  - ApiKeyAuth: []
  - BearerAuth: []

tags:
  - name: Requirements
    description: Create, query, and traverse requirements with full IEEE/INCOSE link traceability.
  - name: Test Cases
    description: Manage test cases including structured steps, parameters, tags, and custom fields.
  - name: Test Plans
    description: Organise test cases into plans and associate them with requirements.
  - name: Test Cycles
    description: Execution rounds (cycles) within a test plan. Supports bulk result import and CI build labels.
  - name: Executions
    description: Trigger test runs, stream real-time step events via SSE, and report results.
  - name: Graph
    description: BFS traversal of the requirement link graph with per-node enrichment.
  - name: Coverage
    description: Requirement coverage analytics at project and individual-requirement level.
  - name: Impact
    description: Identify requirements and tests affected by a change set or explicit requirement list.
  - name: Webhooks
    description: Register outbound webhooks to receive signed events when key actions occur.
  - name: API Keys
    description: Create and revoke long-lived machine tokens for programmatic access.
  - name: Audit
    description: Report audit events the server cannot observe itself. The audit log is append-only; see docs/audit-log.md.
  - name: Chapters
    description: Organise requirements into a hierarchical document outline (chapters and sub-chapters) within a project.
  - name: Semantic Search
    description: AI-powered semantic similarity search for requirements. Requires a BYOK embedding provider configured per project.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Long-lived machine token. Format `rm_live_<hex>`. Create via `POST /api/v1/api-keys`.
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Short-lived JWT obtained from the `/auth/login` endpoint.

  schemas:
    # ── Shared ────────────────────────────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          example: project_id is required

    CursorMeta:
      type: object
      properties:
        next_cursor:
          type: [string, "null"]
          description: Pass as `?cursor=` on the next request. Null when no further pages exist.
          example: eyJjcmVhdGVkX2F0IjoiMjAyNi0wMy0yOFQxMDowMDowMFoiLCJpZCI6ImFiYy0xMjMifQ==
        total_returned:
          type: integer
          example: 50

    CustomFieldFilter:
      type: object
      required: [key, operator]
      properties:
        key:
          type: string
          description: The custom field key as defined in project-field-definitions.
          example: asil_level
        operator:
          type: string
          enum:
            - equals
            - not_equals
            - contains
            - starts_with
            - is_empty
            - is_not_empty
            - is_true
            - is_false
            - greater_than
            - less_than
            - before
            - after
            - between
            - is_any_of
            - contains_any
            - contains_all
            - has_value
            - no_value
        value:
          description: Filter value. Omit for presence/boolean operators. Use array for `between`, `is_any_of`, etc.
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: array
              items:
                oneOf:
                  - type: string
                  - type: number

    # ── Requirements ──────────────────────────────────────────────────────────
    Requirement:
      type: object
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
          description: The requirement-project (spec level) this belongs to, e.g. SysRS, SwRS.
        title:
          type: string
          example: The system shall respond within 200 ms under nominal load.
        description:
          type: string
          nullable: true
        type:
          type: string
          enum: [functional, non-functional, constraint, interface, safety, security, performance]
          default: functional
        status:
          type: string
          enum: [draft, approved, implemented, verified, rejected, obsolete]
          default: draft
        priority:
          type: string
          enum: [low, medium, high, critical]
          default: medium
        category:
          type: string
          nullable: true
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of parent requirement for hierarchical decomposition.
        tags:
          type: array
          items:
            type: string
          example: [safety, ASIL-D]
        assigned_to:
          type: array
          items:
            type: string
            format: uuid
        custom_fields:
          type: object
          additionalProperties: true
          description: Project-defined custom field values keyed by field name.
          example: { asil_level: "D", rationale: "Derived from stakeholder need SN-042" }
        version:
          type: integer
          default: 1
        created_by:
          type: string
          format: uuid
        updated_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    RequirementCreate:
      type: object
      required: [project_id, title, database_id]
      properties:
        project_id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: string
        type:
          type: string
          enum: [functional, non-functional, constraint, interface, safety, security, performance]
        status:
          type: string
          enum: [draft, approved, implemented, verified, rejected, obsolete]
        priority:
          type: string
          enum: [low, medium, high, critical]
        category:
          type: string
        parent_id:
          type: string
          format: uuid
        database_id:
          type: string
          format: uuid
          description: >
            The specification level the requirement belongs to, and the only
            record of it. Required: every requirement belongs to exactly one
            level of its project, so a request without one is refused with 422.
            A level from another project, or a request sending the retired
            `level` field, is refused with 422 too.
        chapter_id:
          type: string
          format: uuid
          description: >
            File the requirement under this chapter. The chapter must be in the
            same project and at the requirement's own spec level, or the request
            is refused with 400.
        tags:
          type: array
          items:
            type: string
        assigned_to:
          type: array
          items:
            type: string
            format: uuid
        custom_fields:
          type: object
          additionalProperties: true

    RequirementPatch:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        type:
          type: string
        status:
          type: string
        priority:
          type: string
        category:
          type: string
        parent_id:
          type: string
          format: uuid
          nullable: true
        database_id:
          type: string
          format: uuid
          description: >
            Move the requirement to another specification level of its project.
            A requirement always belongs to exactly one level, so null is refused
            with 422, as are a level from another project and a request sending
            the retired `level` field. A chapter it was filed under is cleared, unless
            `chapter_id` names a chapter at the new level in the same request.
        chapter_id:
          type: string
          format: uuid
          nullable: true
          description: >
            File the requirement under this chapter, or null to take it out of the
            outline. The chapter must be in the same project and at the
            requirement's spec level after this request, or it is refused with 400.
        tags:
          type: array
          items:
            type: string
        assigned_to:
          type: array
          items:
            type: string
            format: uuid
        custom_fields:
          type: object
          additionalProperties: true

    RequirementLink:
      type: object
      properties:
        id:
          type: string
          format: uuid
        source_id:
          type: string
          format: uuid
        target_id:
          type: string
          format: uuid
        link_type:
          type: string
          enum:
            - satisfies
            - refines
            - derives
            - realizes
            - verifies
            - triggers
            - depends-on
            - conflicts-with
            - relates-to
          description: |
            Directed link type following IEEE/INCOSE ELM conventions. Inverse labels
            (e.g. `satisfied-by`, `verified-by`) are display-only; the DB stores the
            canonical forward direction.
        rationale:
          type: string
          nullable: true
        created_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time

    GraphResult:
      type: object
      properties:
        focal_id:
          type: string
          format: uuid
        nodes:
          type: array
          items:
            allOf:
              - $ref: '#/components/schemas/Requirement'
              - type: object
                properties:
                  latest_execution_status:
                    type: string
                    nullable: true
                    enum: [pass, fail, blocked, skipped, not_run, null]
                  open_defect_count:
                    type: integer
                  coverage_pct:
                    type: integer
                    nullable: true
                  test_count:
                    type: integer
        edges:
          type: array
          items:
            $ref: '#/components/schemas/RequirementLink'

    # ── Test Cases ────────────────────────────────────────────────────────────
    Step:
      type: object
      required: [order, action]
      properties:
        order:
          type: integer
          description: 1-based step number.
          example: 1
        action:
          type: string
          description: What to do.
          example: Navigate to the login page.
        expected_result:
          type: string
          description: What should happen.
          example: The login form is displayed.
        notes:
          type: string
          nullable: true
        type:
          type: string
          enum: [step, shared]
          default: step
        shared_step_id:
          type: string
          format: uuid
          nullable: true
          description: When `type` is `shared`, this reference is expanded inline on GET.

    TestCase:
      type: object
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        suite_id:
          type: string
          format: uuid
          nullable: true
        title:
          type: string
          example: Verify login with valid credentials
        description:
          type: string
          nullable: true
        objective:
          type: string
          nullable: true
        preconditions:
          type: string
          nullable: true
        postconditions:
          type: string
          nullable: true
        steps:
          type: array
          items:
            $ref: '#/components/schemas/Step'
          description: On `GET /:id`, shared-step references are expanded inline.
        test_type:
          type: string
          enum: [manual, automated, semi-automated, exploratory]
          default: manual
        automation_ref:
          type: string
          nullable: true
          description: Path or ID of the automation script (e.g. `tests/login.spec.ts`).
        automation_status:
          type: string
          enum: [not-automated, in-progress, automated, maintenance]
          default: not-automated
        status:
          type: string
          enum: [draft, active, deprecated, archived]
          default: draft
        priority:
          type: string
          enum: [low, medium, high, critical]
          default: medium
        estimated_duration_minutes:
          type: integer
          nullable: true
        tags:
          type: array
          items:
            type: string
        custom_fields:
          type: object
          additionalProperties: true
        owner_id:
          type: string
          format: uuid
        created_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TestCaseCreate:
      type: object
      required: [project_id, title]
      properties:
        project_id:
          type: string
          format: uuid
        suite_id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: string
        objective:
          type: string
        preconditions:
          type: string
        postconditions:
          type: string
        steps:
          type: array
          items:
            $ref: '#/components/schemas/Step'
        test_type:
          type: string
          enum: [manual, automated, semi-automated, exploratory]
        automation_ref:
          type: string
        automation_status:
          type: string
          enum: [not-automated, in-progress, automated, maintenance]
        status:
          type: string
          enum: [draft, active, deprecated, archived]
        priority:
          type: string
          enum: [low, medium, high, critical]
        estimated_duration_minutes:
          type: integer
        tags:
          type: array
          items:
            type: string
        custom_fields:
          type: object
          additionalProperties: true
        owner_id:
          type: string
          format: uuid

    # ── Test Plans ────────────────────────────────────────────────────────────
    TestPlan:
      type: object
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        name:
          type: string
          example: Sprint 14 — Safety Functions
        description:
          type: string
          nullable: true
        owner_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [draft, active, completed, archived]
          default: draft
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        cycles:
          type: array
          items:
            $ref: '#/components/schemas/TestCycle'
          description: Embedded on `GET /:id` only.
        created_at:
          type: string
          format: date-time

    # ── Test Cycles ───────────────────────────────────────────────────────────
    TestCycle:
      type: object
      properties:
        id:
          type: string
          format: uuid
        test_plan_id:
          type: string
          format: uuid
        name:
          type: string
          example: RC1 — HIL rig 2
        description:
          type: string
          nullable: true
        environment:
          type: string
          nullable: true
          example: HIL-rig-2
        build_label:
          type: string
          nullable: true
          description: CI/CD build identifier. Filter via `?build=<label>`.
          example: v2.4.1-rc1
        status:
          type: string
          enum: [not-started, in-progress, completed, aborted]
          default: not-started
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        analytics:
          type: object
          description: Execution status counts. Embedded on list and single-get responses.
          properties:
            not_run: { type: integer }
            pass:    { type: integer }
            fail:    { type: integer }
            blocked: { type: integer }
            skipped: { type: integer }
        created_at:
          type: string
          format: date-time

    BulkResultItem:
      type: object
      required: [test_case_id, status]
      properties:
        test_case_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [pass, fail, blocked, skipped, not_run]
        actual_result:
          type: string
        notes:
          type: string
        executed_at:
          type: string
          format: date-time
        duration_ms:
          type: integer
        external_run_id:
          type: string
          description: Reference to the test result in an external system (e.g. Jenkins build URL).

    BulkResultSummary:
      type: object
      properties:
        imported: { type: integer }
        passed:   { type: integer }
        failed:   { type: integer }
        skipped:  { type: integer }

    # ── Executions ────────────────────────────────────────────────────────────
    ExecutionEvent:
      type: object
      required: [event_type]
      properties:
        event_type:
          type: string
          enum: [step_passed, step_failed, step_blocked, step_skipped, log, telemetry]
        test_case_id:
          type: string
          format: uuid
          nullable: true
        step_order:
          type: integer
          nullable: true
          description: 1-based step index. Required for step_* events.
        status:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        actual_result:
          type: string
          nullable: true
        duration_ms:
          type: integer
          nullable: true
        payload:
          type: object
          nullable: true
          description: Arbitrary JSON for `log` or `telemetry` events.
          additionalProperties: true
        occurred_at:
          type: string
          format: date-time

    # ── Coverage ──────────────────────────────────────────────────────────────
    ProjectCoverageReport:
      type: object
      properties:
        project_id:
          type: string
          format: uuid
        total_requirements:
          type: integer
        covered_requirements:
          type: integer
        uncovered_requirements:
          type: integer
        passing_requirements:
          type: integer
        coverage_pct:
          type: integer
          description: Percentage of requirements with at least one linked test case.
        passing_pct:
          type: integer
          description: Percentage of requirements whose latest linked test passed.
        missing_coverage:
          type: array
          description: Requirements with no linked test cases.
          items:
            type: object
            properties:
              requirement_id: { type: string, format: uuid }
              title:          { type: string }
              priority:       { type: string }

    RequirementCoverageReport:
      type: object
      properties:
        requirement_id:
          type: string
          format: uuid
        requirement_title:
          type: string
        total_tests:
          type: integer
        passing_tests:
          type: integer
        missing_tests:
          type: integer
          description: Tests with no execution result or status `not_run`.
        coverage_pct:
          type: integer
        passing_pct:
          type: integer
        tests:
          type: array
          items:
            type: object
            properties:
              test_case_id:            { type: string, format: uuid }
              test_case_title:         { type: string }
              test_case_status:        { type: string }
              priority:                { type: string }
              test_type:               { type: string }
              coverage_type:           { type: string }
              latest_execution_status: { type: string, nullable: true }
              last_run_at:             { type: string, format: date-time, nullable: true }

    # ── Impact ────────────────────────────────────────────────────────────────
    ImpactReport:
      type: object
      properties:
        project_id:
          type: string
          format: uuid
        change_set:
          type: string
          nullable: true
        explicit_requirement_ids:
          type: array
          items:
            type: string
            format: uuid
        affected_requirements:
          type: array
          description: Seed requirements that match `changeSet` tag or explicit IDs.
          items:
            $ref: '#/components/schemas/Requirement'
        expanded_requirements:
          type: array
          description: Additional requirements discovered via BFS graph expansion (depth 2).
          items:
            $ref: '#/components/schemas/Requirement'
        recommended_tests:
          type: array
          description: Test cases covering affected/expanded requirements, ranked by risk.
          items:
            type: object
            properties:
              test_case_id:      { type: string, format: uuid }
              title:             { type: string }
              status:            { type: string }
              priority:          { type: string }
              test_type:         { type: string }
              automation_status: { type: string }
              covered_req_count: { type: integer }
              latest_result:     { type: string, nullable: true }
              last_run_at:       { type: string, format: date-time, nullable: true }
        total_affected_reqs:
          type: integer
        coverage_gap_count:
          type: integer
          description: Number of affected requirements with no linked test case.

    # ── Webhooks ──────────────────────────────────────────────────────────────
    Webhook:
      type: object
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
          example: https://ci.acme.com/hooks/requiremancer
        events:
          type: array
          items:
            type: string
            enum:
              - test_run_completed
              - requirement_changed
              - coverage_dropped
              - test_case_created
              - test_case_updated
              - defect_created
        is_active:
          type: boolean
          default: true
        secret:
          type: string
          description: HMAC-SHA256 secret. Returned only on `POST`; store it immediately.
        failure_count:
          type: integer
        last_triggered_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    WebhookPayload:
      type: object
      description: |
        Structure of the signed POST body delivered to your webhook URL.

        Verify the delivery by computing `sha256=HMAC-SHA256(secret, raw_body)` and
        comparing it to the `X-Requiremancer-Signature` header.
      properties:
        id:
          type: string
          format: uuid
          description: Unique delivery ID. Also sent as `X-Requiremancer-Delivery-Id`.
        event:
          type: string
          example: test_run_completed
        project_id:
          type: string
          format: uuid
        occurred_at:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true

    # ── API Keys ──────────────────────────────────────────────────────────────
    ApiKey:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: CI Pipeline — Production
        prefix:
          type: string
          example: rm_live_xxxx
          description: First 12 characters of the key, for identification. Never the full key.
        scopes:
          type: array
          items:
            type: string
          description: Reserved for future scope-based access control.
        project_ids:
          type: array
          items:
            type: string
            format: uuid
          description: When non-empty, restricts this key to the listed projects only.
        last_used_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    ApiKeyCreated:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          properties:
            key:
              type: string
              example: rm_live_a3f9c2...
              description: Full plaintext key. Shown exactly once — store it now.

    # ── Chapters ──────────────────────────────────────────────────────────────
    Chapter:
      type: object
      properties:
        id:
          type: string
          format: uuid
        project_id:
          type: string
          format: uuid
        database_id:
          type: string
          format: uuid
          description: >
            The specification level this chapter is the outline of. Every chapter
            belongs to exactly one level, so a project holds one outline tree per
            specification. A chapter's parent must be in the same level, and a
            requirement may only be filed under a chapter of its own level.
        title:
          type: string
          example: Safety Requirements
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: UUID of the parent chapter. Null for top-level chapters.
        position:
          type: number
          format: double
          description: |
            Fractional position used to order chapters at the same level.
            Drag-and-drop reorders chapters by assigning new positions; insert-between
            operations use the midpoint of their neighbours' positions.
          example: 1000
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ChapterCreate:
      type: object
      required: [project_id, database_id, title]
      properties:
        project_id:
          type: string
          format: uuid
        database_id:
          type: string
          format: uuid
          description: >
            Required. The specification level whose outline this chapter joins;
            it must belong to the given project.
        title:
          type: string
          example: Functional Requirements
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: >
            Set to make this a sub-chapter. Omit or null for top-level. The
            parent must be in the same project and the same spec level.
        position:
          type: number
          format: double
          description: Explicit position. Defaults to 0 if omitted.
          example: 1000

    ChapterPatch:
      type: object
      description: All fields are optional; only supplied fields are updated.
      properties:
        title:
          type: string
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: Re-parents the chapter. The server rejects values that would create a cycle.
        position:
          type: number
          format: double

  responses:
    Unauthorized:
      description: Missing or invalid authentication.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Authenticated but not a member of the requested project.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadRequest:
      description: Missing or invalid request parameters.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the rate limit window resets.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

paths:
  # ============================================================
  # REQUIREMENTS
  # ============================================================
  /requirements:
    get:
      operationId: listRequirements
      summary: List requirements
      tags: [Requirements]
      description: |
        Returns a cursor-paginated list of requirements for a project.
        Supports filtering by status, priority, type, spec level, tags, parent, assignee,
        full-text search, and arbitrary custom field expressions.
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema: { type: string }
          description: Comma-separated values, e.g. `draft,approved`.
        - name: priority
          in: query
          schema: { type: string }
          description: Comma-separated values.
        - name: type
          in: query
          schema: { type: string }
        - name: database_id
          in: query
          schema: { type: string }
          description: Comma-separated spec-level ids.
        - name: parent_id
          in: query
          schema: { type: string, format: uuid }
        - name: assigned_to
          in: query
          schema: { type: string, format: uuid }
        - name: search
          in: query
          schema: { type: string }
          description: Case-insensitive substring match on `title`.
        - name: tags
          in: query
          schema: { type: string }
          description: Comma-separated tag values. Requirement must have ALL listed tags.
        - name: custom_field_filters
          in: query
          schema:
            type: string
          description: |
            URL-encoded JSON array of `CustomFieldFilter` objects.
            Example: `[{"key":"asil_level","operator":"is_any_of","value":["C","D"]}]`
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque pagination cursor from `meta.next_cursor`.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Paginated requirement list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Requirement' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

    post:
      operationId: createRequirement
      summary: Create a requirement
      tags: [Requirements]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RequirementCreate' }
      responses:
        '201':
          description: Created requirement.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Requirement' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /requirements/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: getRequirement
      summary: Get a requirement
      tags: [Requirements]
      responses:
        '200':
          description: Single requirement.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Requirement' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateRequirement
      summary: Partially update a requirement
      tags: [Requirements]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RequirementPatch' }
      responses:
        '200':
          description: Updated requirement.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Requirement' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      operationId: deleteRequirement
      summary: Soft-delete a requirement
      tags: [Requirements]
      description: Sets `deleted_at`. The record is excluded from all list/get queries.
      responses:
        '200':
          description: Deleted confirmation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /requirements/bulk-update:
    post:
      operationId: bulkUpdateRequirements
      summary: Partially update many requirements in one request
      tags: [Requirements]
      description: |
        Each row is written exactly as `PATCH /requirements/{id}` writes it, with the
        same permission, spec-level, chapter and custom-field checks, and emits the
        same real-time `patched` event. A row that fails is reported on its own and
        the rest are still written; there is no transaction. The response is one
        compact entry per row, in request order, not the updated rows. At most 500
        rows per request.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [updates]
              properties:
                updates:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [id, patch]
                    properties:
                      id:    { type: string, format: uuid }
                      patch: { $ref: '#/components/schemas/RequirementPatch' }
      responses:
        '200':
          description: One outcome per row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:     { type: string }
                        ok:     { type: boolean }
                        status: { type: integer, description: HTTP-style status of a failed row }
                        error:  { type: string }
                  meta:
                    type: object
                    properties:
                      updated: { type: integer }
                      failed:  { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /requirements/similar:
    get:
      operationId: findSimilarRequirements
      summary: Semantic similarity search
      tags: [Semantic Search]
      description: |
        Embeds the query text using the project's configured provider and returns
        requirements whose stored embedding exceeds the cosine similarity threshold.
        Returns `503 embedding_not_configured` when no provider is set up for the project.
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: Natural-language query text.
          example: The system must authenticate users within 200 ms
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: threshold
          in: query
          schema: { type: number, format: float, minimum: 0, maximum: 1, default: 0.75 }
          description: Minimum cosine similarity score (0–1). Higher values return fewer, more precise results.
      responses:
        '200':
          description: Requirements ranked by similarity to the query.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/Requirement'
                        - type: object
                          properties:
                            similarity_score:
                              type: number
                              format: float
                              description: Cosine similarity (0–1). Higher is more similar.
                              example: 0.9123
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503':
          description: Embedding provider not configured for this project.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: embedding_not_configured }
                  message: { type: string }

  /requirements/{id}/restore:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    post:
      operationId: restoreRequirement
      summary: Restore a soft-deleted requirement
      tags: [Requirements]
      description: >
        Clears `deleted_at`, making the requirement visible to every read again.
        This is the counterpart to `DELETE /requirements/{id}`; unlike every other
        route on a requirement, it does not filter deleted rows out, since the row
        it exists to find is precisely the one the others hide. Restoring a
        requirement that is not deleted is a no-op rather than an error, so a
        retry after a dropped response is safe.
      responses:
        '200':
          description: >
            The restored requirement, with `restored: true`. When the requirement
            was not deleted, returns `{ id, restored: false, reason: "not deleted" }`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Requirement'
                      - type: object
                        properties:
                          restored: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /requirements/{id}/links:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: getRequirementLinks
      summary: Get requirement link graph
      tags: [Requirements]
      description: BFS traversal from this requirement to `depth` hops.
      parameters:
        - name: depth
          in: query
          schema: { type: integer, minimum: 1, maximum: 5, default: 1 }
      responses:
        '200':
          description: Graph of linked requirements.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/GraphResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

    post:
      operationId: createRequirementLink
      summary: Add a link between requirements
      tags: [Requirements]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_id, link_type]
              properties:
                target_id:
                  type: string
                  format: uuid
                link_type:
                  type: string
                  enum: [satisfies, refines, derives, realizes, verifies, triggers, depends-on, conflicts-with, relates-to]
                rationale:
                  type: string
      responses:
        '201':
          description: Created link.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/RequirementLink' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /requirements/{id}/links/{linkId}:
    delete:
      operationId: deleteRequirementLink
      summary: Remove a requirement link
      tags: [Requirements]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: linkId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Deleted confirmation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /requirements/{id}/coverage:
    get:
      operationId: getRequirementCoverage
      summary: Get coverage for a requirement
      tags: [Requirements, Coverage]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Coverage report for this requirement.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/RequirementCoverageReport' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # TEST CASES
  # ============================================================
  /test-cases:
    get:
      operationId: listTestCases
      summary: List test cases
      tags: [Test Cases]
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: suite_id
          in: query
          schema: { type: string, format: uuid }
        - name: test_level_id
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema: { type: string }
          description: Comma-separated values.
        - name: priority
          in: query
          schema: { type: string }
        - name: test_type
          in: query
          schema: { type: string }
          description: Comma-separated values.
        - name: automation_status
          in: query
          schema: { type: string }
        - name: tags
          in: query
          schema: { type: string }
        - name: search
          in: query
          schema: { type: string }
        - name: custom_field_filters
          in: query
          schema: { type: string }
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Paginated test case list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TestCase' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

    post:
      operationId: createTestCase
      summary: Create a test case
      tags: [Test Cases]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TestCaseCreate' }
      responses:
        '201':
          description: Created test case.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /test-cases/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: getTestCase
      summary: Get a test case
      tags: [Test Cases]
      description: Returns the test case with all shared-step references expanded inline.
      responses:
        '200':
          description: Single test case with resolved steps.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCase' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateTestCase
      summary: Partially update a test case
      tags: [Test Cases]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Any subset of TestCaseCreate fields.
      responses:
        '200':
          description: Updated test case.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      operationId: deleteTestCase
      summary: Soft-delete a test case
      tags: [Test Cases]
      responses:
        '200':
          description: Deleted confirmation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /test-cases/{id}/executions:
    get:
      operationId: getTestCaseExecutions
      summary: Get execution history for a test case
      tags: [Test Cases]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: Recent executions across all cycles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:          { type: string, format: uuid }
                        status:      { type: string }
                        executed_at: { type: string, format: date-time }
                        cycle_id:    { type: string, format: uuid }
                        cycle_name:  { type: string }
                        plan_id:     { type: string, format: uuid }
                        plan_name:   { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /test-cases/{id}/coverage-links:
    get:
      operationId: getTestCaseCoverageLinks
      summary: Get requirements covered by a test case
      tags: [Test Cases, Coverage]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Requirements this test case covers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /test-cases/bulk-create:
    post:
      operationId: bulkCreateTestCases
      summary: Create many test cases in one project
      tags: [Test Cases]
      description: |
        Each case is validated exactly as `POST /test-cases` validates one: a title is
        required, `suite_id` and `test_level_id` must belong to the project, and enum
        values must be ones the schema accepts. A refused case is reported on its own
        and the rest are still created; there is no transaction. At most 200 cases.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, test_cases]
              properties:
                project_id: { type: string, format: uuid }
                test_cases:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: { $ref: '#/components/schemas/TestCaseCreate' }
      responses:
        '200':
          description: One outcome per case, in request order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        index:  { type: integer }
                        ok:     { type: boolean }
                        id:     { type: string, format: uuid }
                        status: { type: integer }
                        error:  { type: string }
                  meta:
                    type: object
                    properties:
                      created: { type: integer }
                      failed:  { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  # ============================================================
  # TEST LEVELS, SUITES AND COVERAGE LINKS
  # ============================================================
  /test-levels:
    get:
      operationId: listTestLevels
      summary: List a project's test levels, in order
      tags: [Test Cases]
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Levels ordered by position.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createTestLevel
      summary: Append a test level
      tags: [Test Cases]
      description: Names are unique within a project; a duplicate is refused with 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, name]
              properties:
                project_id:   { type: string, format: uuid }
                name:         { type: string }
                abbreviation: { type: string }
                description:  { type: string }
                color:        { type: string, example: '#1976d2' }
      responses:
        '201':
          description: Created level.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: A level with this name already exists in the project.

  /test-suites:
    get:
      operationId: listTestSuites
      summary: List a project's test suites as a flat, ordered list
      tags: [Test Cases]
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Suites; `parent_suite_id` gives the tree.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createTestSuite
      summary: Create a test suite
      tags: [Test Cases]
      description: A parent suite must be in the same project. The suite is appended after its siblings.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, name]
              properties:
                project_id:      { type: string, format: uuid }
                name:            { type: string }
                description:     { type: string }
                parent_suite_id: { type: string, format: uuid }
      responses:
        '201':
          description: Created suite.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /test-coverage/bulk:
    post:
      operationId: linkTestCoverage
      summary: Link requirements to the test cases that cover them
      tags: [Coverage]
      description: |
        Both ends of each pair must exist and be in the same project, and the caller
        needs test:write there; each project is authorised once. Re-linking an
        existing pair updates its `coverage_type`. A refused pair is reported on its
        own and the rest are still linked. At most 500 links.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [links]
              properties:
                links:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [requirement_id, test_case_id]
                    properties:
                      requirement_id: { type: string, format: uuid }
                      test_case_id:   { type: string, format: uuid }
                      coverage_type:
                        type: string
                        enum: [verifies, validates, tests]
                        default: verifies
      responses:
        '200':
          description: One outcome per link, in request order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        index:          { type: integer }
                        requirement_id: { type: string }
                        test_case_id:   { type: string }
                        ok:             { type: boolean }
                        status:         { type: integer }
                        error:          { type: string }
                  meta:
                    type: object
                    properties:
                      linked: { type: integer }
                      failed: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /test-coverage/{requirementId}/{testCaseId}:
    delete:
      operationId: unlinkTestCoverage
      summary: Remove one coverage link
      tags: [Coverage]
      parameters:
        - name: requirementId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: testCaseId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Deleted confirmation.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # TEST PLANS
  # ============================================================
  /test-plans:
    get:
      operationId: listTestPlans
      summary: List test plans
      tags: [Test Plans]
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema: { type: string }
      responses:
        '200':
          description: List of test plans.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TestPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

    post:
      operationId: createTestPlan
      summary: Create a test plan
      tags: [Test Plans]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, name]
              properties:
                project_id:  { type: string, format: uuid }
                name:        { type: string }
                description: { type: string }
                owner_id:    { type: string, format: uuid }
                status:      { type: string, enum: [draft, active, completed, archived] }
                start_date:  { type: string, format: date }
                end_date:    { type: string, format: date }
      responses:
        '201':
          description: Created test plan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /test-plans/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: getTestPlan
      summary: Get a test plan
      tags: [Test Plans]
      description: Returns the plan with an embedded list of its test cycles.
      responses:
        '200':
          description: Test plan with cycles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestPlan' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateTestPlan
      summary: Partially update a test plan
      tags: [Test Plans]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:        { type: string }
                description: { type: string }
                owner_id:    { type: string, format: uuid }
                status:      { type: string }
                start_date:  { type: string, format: date }
                end_date:    { type: string, format: date }
      responses:
        '200':
          description: Updated test plan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestPlan' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      operationId: deleteTestPlan
      summary: Delete a test plan
      tags: [Test Plans]
      responses:
        '200':
          description: Deleted confirmation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /test-plans/{id}/requirements:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: listTestPlanRequirements
      summary: List requirements associated with a plan
      tags: [Test Plans]
      responses:
        '200':
          description: Associated requirements.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Requirement' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

    post:
      operationId: addTestPlanRequirement
      summary: Associate a requirement with a plan
      tags: [Test Plans]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requirement_id]
              properties:
                requirement_id: { type: string, format: uuid }
      responses:
        '201':
          description: Association created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      test_plan_id:    { type: string, format: uuid }
                      requirement_id:  { type: string, format: uuid }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /test-plans/{id}/requirements/{reqId}:
    delete:
      operationId: removeTestPlanRequirement
      summary: Remove a requirement from a plan
      tags: [Test Plans]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: reqId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Unlinked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      test_plan_id:   { type: string, format: uuid }
                      requirement_id: { type: string, format: uuid }
                      unlinked:       { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # TEST CYCLES
  # ============================================================
  /test-cycles:
    get:
      operationId: listTestCycles
      summary: List test cycles
      tags: [Test Cycles]
      parameters:
        - name: test_plan_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema: { type: string }
        - name: build
          in: query
          schema: { type: string }
          description: Filter by build label (e.g. `v2.4.1-rc1`).
        - name: environment
          in: query
          schema: { type: string }
      responses:
        '200':
          description: List of test cycles with analytics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TestCycle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

    post:
      operationId: createTestCycle
      summary: Create a test cycle
      tags: [Test Cycles]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [test_plan_id, name]
              properties:
                test_plan_id: { type: string, format: uuid }
                name:         { type: string }
                description:  { type: string }
                environment:  { type: string }
                build_label:  { type: string }
                status:       { type: string, enum: [not-started, in-progress, completed, aborted] }
      responses:
        '201':
          description: Created test cycle.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCycle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /test-cycles/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: getTestCycle
      summary: Get a test cycle
      tags: [Test Cycles]
      responses:
        '200':
          description: Test cycle with analytics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCycle' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateTestCycle
      summary: Partially update a test cycle
      tags: [Test Cycles]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:         { type: string }
                description:  { type: string }
                environment:  { type: string }
                build_label:  { type: string }
                status:       { type: string, enum: [not-started, in-progress, completed, aborted] }
                completed_at: { type: string, format: date-time }
      responses:
        '200':
          description: Updated test cycle. Fires `test_run_completed` webhook when status is set to `completed`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCycle' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      operationId: deleteTestCycle
      summary: Delete a test cycle
      tags: [Test Cycles]
      responses:
        '200':
          description: Deleted confirmation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /test-cycles/{id}/results:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: listCycleResults
      summary: List execution results for a cycle
      tags: [Test Cycles]
      parameters:
        - name: status
          in: query
          schema: { type: string }
        - name: test_case_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Execution records.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    post:
      operationId: bulkImportResults
      summary: Bulk import test results into a cycle
      tags: [Test Cycles]
      description: |
        Ideal for CI/CD pipelines. Inserts new execution records; skips test cases already
        recorded in this cycle (idempotent). Auto-starts the cycle if it was `not-started`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [results]
              properties:
                results:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/BulkResultItem' }
      responses:
        '200':
          description: Import summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BulkResultSummary' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # EXECUTIONS
  # ============================================================
  /executions:
    post:
      operationId: triggerExecution
      summary: Trigger a new test execution (create a cycle)
      tags: [Executions]
      description: |
        Creates a new `test_cycle` from a plan. By default the cycle is started immediately
        (`status: in-progress`). Set `auto_start: false` to create it in `not-started` state.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [test_plan_id, name]
              properties:
                test_plan_id: { type: string, format: uuid }
                name:         { type: string, example: "CI run — main@a3f9c2" }
                description:  { type: string }
                environment:  { type: string, example: HIL-rig-2 }
                build_label:  { type: string, example: v2.4.1-rc1 }
                auto_start:
                  type: boolean
                  default: true
      responses:
        '201':
          description: Created execution (test cycle).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCycle' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /executions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
        description: Test cycle ID.

    get:
      operationId: getExecution
      summary: Get execution summary
      tags: [Executions]
      description: Returns the cycle with analytics and all execution records.
      responses:
        '200':
          description: Execution summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/TestCycle'
                      - type: object
                        properties:
                          plan:
                            type: object
                            properties:
                              id:   { type: string, format: uuid }
                              name: { type: string }
                          executions:
                            type: array
                            items:
                              type: object
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateExecution
      summary: Update execution status
      tags: [Executions]
      description: Mark as `completed` to trigger the `test_run_completed` webhook.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:       { type: string, enum: [not-started, in-progress, completed, aborted] }
                environment:  { type: string }
                build_label:  { type: string }
                completed_at: { type: string, format: date-time }
      responses:
        '200':
          description: Updated execution.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TestCycle' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /executions/{id}/events:
    post:
      operationId: postExecutionEvent
      summary: Report a step or telemetry event
      tags: [Executions]
      description: |
        Persists the event to `test_step_executions` (for step events) and fans it out
        to all active SSE subscribers on `GET /executions/{id}/stream`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExecutionEvent' }
      responses:
        '201':
          description: Event accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ExecutionEvent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /executions/{id}/stream:
    get:
      operationId: streamExecution
      summary: Stream execution events (SSE)
      tags: [Executions]
      description: |
        Server-Sent Events stream. Replays up to 500 stored step results on connect,
        then delivers live events as they are posted to `POST /executions/{id}/events`.

        Each event is a JSON object on a `data:` line followed by `\n\n`.
        A `: heartbeat` comment is sent every 30 seconds.

        **Example consumer (JavaScript):**
        ```js
        const es = new EventSource('/api/v1/executions/abc/stream', {
          headers: { 'X-API-Key': 'rm_live_...' }
        });
        es.onmessage = (e) => console.log(JSON.parse(e.data));
        ```
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: SSE stream.
          content:
            text/event-stream:
              schema:
                type: string
                description: 'NDJSON event stream. Each line is `data: <JSON>

`.'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # GRAPH
  # ============================================================
  /graph:
    get:
      operationId: getGraph
      summary: Traverse the traceability graph around a requirement, signal or test case
      tags: [Graph]
      description: |
        Walks outward from a focal entity. What is walked depends on its type:
        - `requirement:{uuid}` — requirement links to `depth` hops, plus entity links to at most 2 hops.
        - `signal:{uuid}` and `work_item:{uuid}` — entity links to `depth` hops.
        - `test_case:{uuid}` — seeded from test coverage. The requirements the test case verifies
          are hop 1, and their requirement-link neighbourhood extends to `depth` − 1 further hops.
          The test case's own entity links are included. The response also carries
          `covered_requirement_ids`. Requirements outside the test case's project, or deleted, are omitted.

        Every node carries `_entity_type`. Each requirement node is enriched with:
        - `latest_execution_status` — result of the most recent linked test execution
        - `open_defect_count` — number of open (non-closed) defects linked to the requirement
        - `coverage_pct` — percentage of linked tests that last passed
        - `test_count` — number of test cases covering this requirement

        Entity-link edges carry `_link_origin: entity_links`.
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string }
          description: "Entity reference: `requirement:{uuid}`, `signal:{uuid}` or `test_case:{uuid}`."
          example: "requirement:a3f9c200-0000-0000-0000-000000000001"
        - name: depth
          in: query
          schema: { type: integer, minimum: 1, maximum: 10, default: 2 }
      responses:
        '200':
          description: Graph of requirements and links.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/GraphResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # COVERAGE
  # ============================================================
  /coverage/projects/{projectId}:
    get:
      operationId: getProjectCoverage
      summary: Get overall project coverage
      tags: [Coverage]
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Coverage report.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ProjectCoverageReport' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /coverage/requirements/{reqId}:
    get:
      operationId: getCoverageForRequirement
      summary: Get coverage for a single requirement
      tags: [Coverage]
      parameters:
        - name: reqId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Coverage report.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/RequirementCoverageReport' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # IMPACT
  # ============================================================
  /impact:
    get:
      operationId: getImpactAnalysis
      summary: Impact analysis for a change set
      tags: [Impact]
      description: |
        Identifies which requirements and test cases are affected by a change.
        Supply a tag name, explicit requirement IDs, or both — the union is analysed.

        The response includes:
        - **affected_requirements** — seed set matching tag/IDs
        - **expanded_requirements** — additional requirements discovered via BFS (depth 2)
        - **recommended_tests** — tests covering affected requirements, ranked
          by risk (fail > blocked > not_run or no result > pass) then by number of requirements covered
        - **coverage_gap_count** — affected requirements with no linked test case

        **Example — find all tests to run for the `braking-refactor` tag:**
        ```
        GET /api/v1/impact?project_id=<uuid>&changeSet=braking-refactor
        ```
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: changeSet
          in: query
          schema: { type: string }
          description: Tag value on requirements. All requirements with this tag are included.
        - name: requirementIds[]
          in: query
          schema:
            type: array
            items: { type: string, format: uuid }
          style: form
          explode: true
          description: Explicit requirement UUIDs to include. Repeat the parameter for multiple values.
      responses:
        '200':
          description: Impact report.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ImpactReport' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  # ============================================================
  # WEBHOOKS
  # ============================================================
  /webhooks:
    get:
      operationId: listWebhooks
      summary: List webhooks for a project
      tags: [Webhooks]
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Webhook list (secrets excluded).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

    post:
      operationId: createWebhook
      summary: Register a webhook
      tags: [Webhooks]
      description: |
        The `secret` field in the response is the HMAC-SHA256 signing secret.
        It is shown exactly once — store it now. Use it to verify incoming deliveries:

        ```js
        const sig = crypto
          .createHmac('sha256', secret)
          .update(rawBody)
          .digest('hex');
        assert(sig === req.headers['x-requiremancer-signature'].replace('sha256=', ''));
        ```
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, url, events]
              properties:
                project_id: { type: string, format: uuid }
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [test_run_completed, requirement_changed, coverage_dropped, test_case_created, test_case_updated, defect_created]
                is_active:
                  type: boolean
                  default: true
      responses:
        '201':
          description: Created webhook (includes secret).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: getWebhook
      summary: Get a webhook
      tags: [Webhooks]
      responses:
        '200':
          description: Webhook details (secret excluded).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateWebhook
      summary: Update a webhook
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:       { type: string, format: uri }
                events:    { type: array, items: { type: string } }
                is_active: { type: boolean }
      responses:
        '200':
          description: Updated webhook.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      operationId: deleteWebhook
      summary: Delete a webhook
      tags: [Webhooks]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /webhooks/{id}/ping:
    post:
      operationId: pingWebhook
      summary: Send a test ping event to a webhook
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Ping queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      queued:     { type: boolean }
                      webhook_id: { type: string, format: uuid }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # SEMANTIC SEARCH — EMBEDDING CONFIG
  # ============================================================
  /projects/{projectId}/embedding-config:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string, format: uuid }

    get:
      operationId: getEmbeddingConfig
      summary: Get embedding provider config
      tags: [Semantic Search]
      description: Returns the embedding configuration for the project. The API key is never returned. Admin role required.
      responses:
        '200':
          description: Embedding configuration summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      configured:    { type: boolean }
                      provider:      { type: string, nullable: true, enum: [openai, azure_openai] }
                      model:         { type: string, nullable: true }
                      azure_endpoint: { type: string, nullable: true }
                      api_version:   { type: string, nullable: true }
                      api_key_set:   { type: boolean }
                      embedding_reindex_requested_at: { type: string, format: date-time, nullable: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateEmbeddingConfig
      summary: Save embedding provider config
      tags: [Semantic Search]
      description: |
        Saves (or updates) the embedding provider configuration for the project.
        The API key is stored encrypted at rest using AES-256-GCM.

        If the `model` or `provider` field changes from the current value, the server returns
        `409 model_change_requires_confirmation` unless `model_change_confirmed: true` is passed.
        Admin role required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  type: string
                  enum: [openai, azure_openai]
                model:
                  type: string
                  enum: [text-embedding-3-small, text-embedding-ada-002]
                api_key:
                  type: string
                  description: Plaintext API key — encrypted before storage. Omit to keep existing key.
                azure_endpoint:
                  type: string
                  description: Azure resource hostname prefix (e.g. `my-resource`). Required for `azure_openai`.
                api_version:
                  type: string
                  description: Azure OpenAI API version. Defaults to `2024-02-01`.
                model_change_confirmed:
                  type: boolean
                  description: Set `true` to confirm model/provider change and accept that existing vectors will be invalidated.
      responses:
        '200':
          description: Updated config summary (API key redacted).
        '409':
          description: Model change requires explicit confirmation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:         { type: string, example: model_change_requires_confirmation }
                  message:       { type: string }
                  current_model: { type: string }
                  new_model:     { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/BadRequest' }

  /projects/{projectId}/embedding-config/test:
    post:
      operationId: testEmbeddingConfig
      summary: Test embedding provider connection
      tags: [Semantic Search]
      description: 'Sends a minimal test request to the configured provider. Returns `{ ok: true, dims: 1536 }` on success. Admin role required.'
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Connection successful.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok:   { type: boolean, example: true }
                      dims: { type: integer, example: 1536 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503':
          description: Provider not configured or connection failed.

  /projects/{projectId}/re-embed:
    post:
      operationId: triggerReEmbed
      summary: Re-embed all project requirements
      tags: [Semantic Search]
      description: |
        Asynchronously re-generates embeddings for every non-deleted requirement in the project.
        Returns `202 Accepted` immediately; embedding happens in the background.

        Use this after changing the embedding model or when bulk-importing requirements without
        triggering individual write events. Admin role required.
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '202':
          description: Re-embed queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  queued:     { type: boolean, example: true }
                  project_id: { type: string, format: uuid }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503':
          description: Embedding provider not configured.

  # ============================================================
  # API KEYS
  # ============================================================
  /audit/exports:
    post:
      operationId: recordExport
      summary: Report a client-side data export
      tags: [Audit]
      description: |
        Records a `data.exported` entry in the append-only audit log for an export the
        client built itself, such as a grid's CSV download. The caller must be able to
        read the exported kind of content in the project; otherwise nothing is recorded.
        Entries are marked `client_reported: true`, because the server did not see the
        data leave. Every server-observed event (sign-in, API-key use, access changes,
        deletions, artifact downloads) is audited without any call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, entity, row_count]
              properties:
                project_id:
                  type: string
                  format: uuid
                entity:
                  type: string
                  enum: [requirement, signal, test_case, work_item]
                  description: The kind of content exported; read access to it is checked.
                row_count:
                  type: integer
                  minimum: 0
                format:
                  type: string
                  enum: [csv]
                  default: csv
                columns:
                  type: array
                  items: { type: string }
                  description: Keys of the columns written. At most 200 are recorded.
      responses:
        '201':
          description: The export was recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      recorded: { type: boolean, example: true }
        '400':
          description: A field is missing or invalid.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The caller cannot read that content in that project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /project-bundles/{projectId}:
    get:
      operationId: exportProjectBundle
      summary: Export a whole project as a bundle
      tags: [Projects]
      description: |
        Streams everything belonging to one project as newline-delimited JSON, one record
        per line, for moving it to another Requiremancer instance or recovering one tenant
        (WI-170). Records come in this order: `header`, then `person` and `team` records
        naming the people (by email and display name only) and organisation teams the
        project refers to, then `row` records table by table, parents before children,
        then `file` records carrying stored objects as base64, and finally `end` with the
        counts. A bundle without an `end` record was cut short and must not be imported.

        Rows keep their UUIDs and REQ/WI keys. Accounts, credentials, invitations,
        webhooks and the audit log are never included; `src/projectTransfer/catalogue.js`
        lists every table and why it is or is not carried. Requires `project:write`,
        which project admins hold, and is recorded as `data.exported` in the audit log.
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The bundle, streamed.
          headers:
            Content-Disposition:
              schema: { type: string }
              description: attachment, with a `.rmbundle.ndjson` file name.
          content:
            application/x-ndjson:
              schema:
                type: string
                description: One JSON record per line, as described above.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The caller is not an admin of the project, or the key is not scoped to it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No such project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api-keys:
    get:
      operationId: listApiKeys
      summary: List your API keys
      tags: [API Keys]
      description: Returns all non-revoked API keys owned by the authenticated user. Key hashes are never returned.
      responses:
        '200':
          description: List of API keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }

    post:
      operationId: createApiKey
      summary: Create an API key
      tags: [API Keys]
      description: |
        The `key` field in the response is the full plaintext token (`rm_live_<hex>`).
        It is shown exactly once — store it in a secrets manager now.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  example: CI Pipeline — Staging
                scopes:
                  type: array
                  items: { type: string }
                  description: Reserved for future scope-based access control.
                project_ids:
                  type: array
                  items: { type: string, format: uuid }
                  description: Restrict this key to specific projects. Empty = all accessible projects.
      responses:
        '201':
          description: Created API key (includes plaintext key).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ApiKeyCreated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api-keys/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }

    patch:
      operationId: updateApiKey
      summary: Update an API key
      tags: [API Keys]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:        { type: string }
                scopes:      { type: array, items: { type: string } }
                project_ids: { type: array, items: { type: string, format: uuid } }
      responses:
        '200':
          description: Updated API key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      operationId: revokeApiKey
      summary: Revoke an API key
      tags: [API Keys]
      description: Sets `revoked_at`. The key is immediately rejected on subsequent requests.
      responses:
        '200':
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:      { type: string, format: uuid }
                      revoked: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # CHAPTERS
  # ============================================================
  /chapters:
    get:
      operationId: listChapters
      summary: List chapters for a project
      tags: [Chapters]
      description: |
        Returns all chapters for a project as a flat list ordered by
        `(parent_id NULLS FIRST, position ASC, created_at ASC)`.
        The client reconstructs the tree from `parent_id` references.
        Pass `database_id` to return one specification's outline; omit it to get
        every level's outline together, which is what the "All Levels" view
        groups by level to display.
        Real-time updates are broadcast via the Feathers WebSocket channel
        for the project (`chapter-created`, `chapter-updated`, `chapter-deleted`).
      parameters:
        - name: project_id
          in: query
          required: true
          schema: { type: string, format: uuid }
          description: Project to list chapters for.
        - name: database_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: >
            Return only the outline of this specification level, and scope the
            requirement counts to it. Omit for every level's outline.
        - name: with_counts
          in: query
          required: false
          schema: { type: string, enum: ['1', 'true'] }
          description: >
            Attach `requirement_count` to each chapter, counted in SQL over the
            whole document. The outline must use this rather than counting the
            rows it has paged in, which under-reports every chapter.
      responses:
        '200':
          description: Flat list of chapters.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Chapter' }
                  meta:
                    type: object
                    properties:
                      total: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

    post:
      operationId: createChapter
      summary: Create a chapter
      tags: [Chapters]
      description: |
        Creates a new chapter (or sub-chapter). Position uses fractional indexing —
        to insert between two existing chapters pass their midpoint, e.g.
        `position: 1500` to sit between chapters at `1000` and `2000`.
        A `chapter-created` event is broadcast in real time to all project members.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChapterCreate' }
      responses:
        '201':
          description: Created chapter.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Chapter' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /chapters/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
        description: Chapter UUID.

    get:
      operationId: getChapter
      summary: Get a single chapter
      tags: [Chapters]
      responses:
        '200':
          description: Chapter object.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Chapter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      operationId: updateChapter
      summary: Update a chapter
      tags: [Chapters]
      description: |
        Updates `title`, `parent_id`, and/or `position`. The server prevents
        cycles — setting `parent_id` to a descendant of this chapter returns 400.
        A `chapter-updated` real-time event is broadcast to all project members.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChapterPatch' }
      responses:
        '200':
          description: Updated chapter.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Chapter' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      operationId: deleteChapter
      summary: Delete a chapter
      tags: [Chapters]
      description: |
        Deletes the chapter and all of its descendants (CASCADE).
        The `chapter_id` on any requirements in those chapters is set to `NULL`.
        A `chapter-deleted` real-time event is broadcast to all project members.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /chapters/requirements/{reqId}/chapter:
    patch:
      operationId: assignRequirementChapter
      summary: Assign or clear a requirement's chapter
      tags: [Chapters]
      description: |
        Sets the `chapter_id` on a requirement. Pass `chapter_id: null` to remove
        the chapter assignment (move the requirement to the unclassified section).
        The chapter must be in the same project and at the requirement's own spec
        level; any other chapter is refused with 400. The same rule applies to
        `chapter_id` on `POST` and `PATCH /requirements`.
        Emits a `requirement-updated` real-time event via the Feathers requirements
        service so all connected clients reflect the change immediately.
      parameters:
        - name: reqId
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: Requirement UUID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                chapter_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Chapter UUID to assign, or null to clear.
              example:
                chapter_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
      responses:
        '200':
          description: Updated requirement with the new chapter_id.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Requirement' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
