openapi: 3.1.0

info:
  title: Fareeqy REST API
  # Bumped with every documented change, and mirrored by the newest entry in
  # lib/api-changelog.ts — scripts/check-api-version.mjs fails the build if the
  # two ever disagree, which is what stops a backend sync from silently
  # reverting this line.
  version: 1.1.0
  summary: Authenticated public REST API for Fareeqy project management.
  description: |
    The Fareeqy public REST API (`/api/v1`) lets a company automate its projects,
    task lists, tasks, majlis discussions, files, folders and calendar events over
    plain JSON with a Bearer API key.

    **Preview.** This surface is gated behind the per-company Flipper feature flag
    `rest_api` (off by default). While the flag is disabled for a company, every
    authenticated endpoint returns **404** — the surface is hidden, not merely
    forbidden. Authentication is checked first (a bad key is still 401), then the
    flag, so the 404 only appears once a valid key has been presented.

    With the flag on, the PLAN decides access: Free and Pro carry no API calls, so
    a key on either is refused **403** `plan_upgrade_required`.

    There is deliberately no "my tasks" or "my personal tasks" endpoint. A key
    belongs to the COMPANY and the person on it merely created it, so "assigned
    to me" has no answer a company credential can give. Both lists remain on the
    MCP server, whose Personal Access Token really is personal.

    ## Vocabulary

    Product vocabulary and the internal schema differ, and this API follows the
    **product**:

    | API term | Path segment | What it is |
    |---|---|---|
    | Task list | `/lists` | A container that groups tasks within a project (schema model `Task`) |
    | Task | `/tasks` | The actionable item a user checks off (schema model `Todo`) |
    | Majlis topic / discussion | `/discussions` | A project discussion thread |

    ## Identifiers

    Every path segment accepts **either** shape below. Reads hand back the ref, so
    a client that follows its own output stays on refs without deciding anything.

    - **Refs** are the current identifier: a short ASCII handle like `prj-8f3kd`,
      `lst-4m2qp`, `tsk-9wb7t`, `mjl-6khz2`, `fil-3qd8n` — the same string the
      فريقي web app shows in its address bar, so a person can paste a link
      straight into a script. Three properties are worth knowing: a ref never
      changes when the record is renamed; it needs no percent-encoding; and the
      prefix is checked, so `/lists/tsk-…` is a 404 rather than a wrong
      record of the wrong kind. This is what the `id` field of every project,
      task list, task, discussion and file response contains.
    - **Slugs** are the older identifier and keep working everywhere, including
      slugs retired by a rename. A slug is unique only **within its parent**, so
      URLs stay fully nested and every slug is resolved through its parent (a
      task-list slug can repeat across projects). Never assume a slug is globally
      unique, and remember an Arabic slug must be percent-encoded.
    - **Numeric ids** address the records that have no ref: folders, events, and
      comments. A file accepts both its `fil-` ref and its numeric `file_id`.
      These ids are global, so the API always drills a lookup through the parent
      project (or, for events, the caller's accessible set) — a numeric id from
      another company resolves to 404, never a leak.

    Parameters are still **named** `*_slug` in the paths and the schemas below.
    That is compatibility, not a constraint: renaming a public parameter would
    break every caller, and widening what one accepts breaks nobody. Read them as
    "handle".

    ## Authentication

    Send an `ApiKey` as an HTTP Bearer credential:

    ```
    Authorization: Bearer frq_api_xxxxxxxxxxxxxxxxxxxxxxxx
    ```

    API keys carry the `frq_api_` prefix and live in their own table, **isolated
    from the MCP server's Personal Access Tokens** (`frq_`): a key created for one
    surface cannot authenticate against the other.

    A key acts for the **company**, at the reach its own permissions describe. It
    sees every project the company has, private ones included, and it is not
    limited by the role of whoever created it. What bounds it is the operation
    allowlist below, the company itself (another company's record is a 404), and
    state that was never a permission: an archived project stays frozen and a
    read-only subscription stays read-only.

    ## Two-layer authorization

    Every endpoint declares the single **operation** it requires (a stable
    `<resource>:<capability>` string, e.g. `projects:write`). A key is gated twice:

    1. **Scope** (the ceiling) — `read` or `write`. A `write` key also carries
       `read`. The scope decides which operations a key may *ever* hold.
    2. **`allowed_operations`** (the operative gate) — the explicit list of
       operations this key may call, validated to be a subset of what its scope
       permits. Empty means "nothing"; a key is never accidentally open.

    Both must hold for a request to run: the operation must be in the key's
    allowlist AND its scope must satisfy the operation's capability, otherwise the
    request is **403 forbidden** and audited as `denied`.

    **Deletes are opt-in.** `destructive` operations need the `write` scope but are
    deliberately excluded from the write preset's default grant; an owner ticks each
    delete operation on a key deliberately. See the `x-operation-catalog` extension
    below for the full list.

    ## Envelopes

    - **Single resource** → `{ "data": <Resource> }`
    - **Collection** → `{ "data": [<Resource>], "meta": { total, limit, offset, count } }`
    - **Delete** → `{ "data": { "deleted": true, ... } }`
    - **Error** → `{ "error": { "code": "<slug>", "message": "<human message>" } }`

    ## Statuses every endpoint can return

    Beyond the per-operation list below, the base controller rescues at one place, so
    the same failures reach every endpoint: **401** (bad or missing key), **403**
    (`forbidden` or `plan_upgrade_required`), **404** (not found, not accessible, or
    the `rest_api` flag is off for the company), **429** (burst throttle or spent
    daily allowance). Any **write** can additionally return **409** `conflict` when a
    uniqueness constraint loses a race; it is safe to retry.

    ## Quickstart

    Every `x-codeSamples` snippet below assumes these two variables, so set them
    once and the samples paste as-is:

    ```bash
    export FRQ_BASE="https://app.fareeqy.com/api/v1"
    export FRQ_KEY="frq_api_..."          # shown once, at creation
    ```

    Confirm the key before anything else. `GET /me` describes the **key** rather
    than a person, and returns the exact operation list it carries:

    ```bash
    curl -s "$FRQ_BASE/me" -H "Authorization: Bearer $FRQ_KEY"
    ```

    The whole create-a-project-with-work-in-it flow, end to end. Note that ids are
    **refs** — short ASCII handles like `prj-8f3kd`, the same ones the فريقي web
    app puts in its address bar — and that each is captured from the response
    rather than typed:

    ```bash
    PROJECT=$(curl -s -X POST "$FRQ_BASE/projects" \
      -H "Authorization: Bearer $FRQ_KEY" -H "Content-Type: application/json" \
      -d '{"name":"Website launch","start_date":"2026-08-05"}' | jq -r .data.id)

    LIST=$(curl -s -X POST "$FRQ_BASE/projects/$PROJECT/lists" \
      -H "Authorization: Bearer $FRQ_KEY" -H "Content-Type: application/json" \
      -d '{"title":"Before launch","priority":"high"}' | jq -r .data.id)

    TASK=$(curl -s -X POST "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks" \
      -H "Authorization: Bearer $FRQ_KEY" -H "Content-Type: application/json" \
      -d '{"title":"Proofread the homepage","due_at":"2026-08-20"}' | jq -r .data.id)

    curl -s -X POST "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks/$TASK/complete" \
      -H "Authorization: Bearer $FRQ_KEY"
    ```

    Two things that trip up a first integration:

    - **Request bodies are flat.** Send `{"name": "..."}`, never
      `{"project": {"name": "..."}}`. A wrapped body is not an error — the fields
      simply are not found, and you get a validation failure naming a field you
      thought you sent.
    - **Unknown fields are dropped silently** (strong parameters). Misspelling
      `titel` does not fail; it creates a record without a title.

    ## Pagination

    Collections take `limit` (default 50, max 100, clamped) and `offset` (default 0)
    query params. `meta.total` is the full unfiltered count; `meta.count` is the
    number of records in the returned page.

    ## Rate limiting and the daily quota

    Two independent layers, and they refuse differently.

    **Burst.** Each API key is throttled to **300 requests per minute** (Rack::Attack
    `api_v1/token`, keyed on a hash of the Bearer header), and a blanket 300/min
    per-IP throttle (`api/ip`) also applies. Crossing either returns **429**.

    **Daily quota.** Every request also spends one call from the company's daily API
    allowance. The allowance is per **company**, not per key, so minting extra keys
    does not raise it, and the day resets at midnight in the company's own time zone.

    | Plan | Daily API calls |
    |---|---|
    | Free (المجاني) | 0 (no API access) |
    | Pro (الاحترافي) | 0 (no API access) |
    | Advanced (المتطور) | 1,000 |
    | Productive (الإنتاجي) | 10,000 |

    A plan at **0** is not being rate-limited, it has no API access at all: the
    refusal is **403 `plan_upgrade_required`** and carries no `Retry-After`, because
    waiting will never help. A plan that *has* access and has spent today's calls
    gets **429 `rate_limit_exceeded`** with a `Retry-After` pointing at the company's
    next midnight. Treating those two as the same failure sends a well-behaved client
    into a retry loop that can never succeed.

    `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` are set on
    **every** served response, not only on refusals, so a client can slow down before
    it reaches zero. On an uncapped plan the first two read `unlimited`.
    `X-RateLimit-Reset` is omitted when the plan carries no access, for the same
    reason `Retry-After` is.

  x-operation-catalog:
    description: >
      The full operation catalog (Api::OperationRegistry). read needs the read
      scope; write needs the write scope; destructive needs the write scope AND is
      excluded from the write preset's default grant (opt-in per key). `account`
      has no destructive operation.
    account:
      read: [account:read]
    projects:
      read: [projects:read]
      write: [projects:write]
      destructive: [projects:destructive]
    task_lists:
      read: [task_lists:read]
      write: [task_lists:write]
      destructive: [task_lists:destructive]
    tasks:
      read: [tasks:read]
      write: [tasks:write]
      destructive: [tasks:destructive]
    discussions:
      read: [discussions:read]
      write: [discussions:write]
      destructive: [discussions:destructive]
    comments:
      read: [comments:read]
      write: [comments:write]
    files:
      read: [files:read]
      write: [files:write]
      destructive: [files:destructive]
    events:
      read: [events:read]
      write: [events:write]
      destructive: [events:destructive]

servers:
  - url: https://app.fareeqy.com/api/v1
    description: Production. The app runs on app.fareeqy.com (fareeqy.com is the
      marketing site and answers no API call). The base path (/api/v1) is part of
      the server URL, so operation paths below are written relative to it
      (e.g. /projects).

security:
  - bearerAuth: []

tags:
  - name: Account
    description: The calling key itself and workspace-wide search.
  - name: Events
    description: Company calendar events. Numeric ids.
  - name: Projects
    description: Projects the caller can access.
  - name: Members
    description: A project's assignable people.
  - name: Task lists
    description: Containers that group tasks within a project. Ref ids (lst-); a slug still resolves.
  - name: Tasks
    description: The actionable items nested under a task list. Ref ids (tsk-); a slug still resolves.
  - name: Comments
    description: Comments on a task list or task, read and written.
  - name: Discussions
    description: Majlis discussion topics within a project. Ref ids (mjl-); a slug still resolves.
  - name: Files
    description: Project files and external links. Numeric ids; the flat delete address takes a fil- ref.
  - name: Folders
    description: Folders in a project's files section. Numeric ids.

paths:

  # ─────────────────────────── Account ───────────────────────────
  /me:
    get:
      tags: [Account]
      operationId: getMe
      summary: The calling key and the company it acts for
      description: |
        Returns the key itself — its name, its access level and the exact
        operations it may call — plus the company it acts for. The canonical
        "is my key valid, and what does it reach?" check. `created_by` names the
        person who created the key; it is a signature, not the identity the call
        runs as. Requires operation `account:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/me" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The authenticated account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /search:
    get:
      tags: [Account]
      operationId: search
      summary: Unified workspace search
      description: |
        Finds projects, task lists, tasks, folders, files and majlis topics by name
        (Arabic-normalized, access-scoped). Returns the slugs/ids the other
        endpoints take. Each kind is capped independently (`per_type`); `truncated`
        names every kind that had more matches than were returned. A blank `q`
        returns empty results. Requires operation `account:read`.
      parameters:
        - name: q
          in: query
          description: The search term. Blank returns empty results.
          schema: { type: string }
        - name: types
          in: query
          description: Restrict to these kinds. Omit for all kinds. Unknown names are ignored.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum: [project, task_list, task, folder, file, discussion]
        - name: per_type
          in: query
          description: Max hits per kind (default 5, max 25, clamped).
          schema: { type: integer, default: 5, minimum: 1, maximum: 25 }
      responses:
        '200':
          description: Search results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      query: { type: string }
                      results:
                        type: array
                        items: { $ref: '#/components/schemas/SearchResult' }
                      truncated:
                        type: array
                        description: The kinds that had more matches than were returned.
                        items: { type: string }
              example:
                data:
                  query: تصميم
                  results:
                    - { type: project, id: prj-8f3kd, title: تطوير الموقع, project_slug: تطوير-الموقع, archived: false }
                    - type: task
                      id: tsk-9wb7t
                      title: تصميم الهيدر
                      project_slug: تطوير-الموقع
                      task_list_slug: الصفحة-الرئيسية
                      task_slug: تصميم-الهيدر
                      completed: false
                    - { type: folder, name: التصاميم, project_slug: تطوير-الموقع, folder_id: 42 }
                  truncated: [task]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────── Events ────────────────────────────
  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List calendar events
      description: |
        The caller's accessible events (organized or invited). Defaults to upcoming
        only; an explicit `from`/`to` window wins over `include_past`. Bounds and the
        "upcoming" cutoff resolve in the company time zone. Requires operation
        `events:read`.
      parameters:
        - name: from
          in: query
          description: Lower bound on start time (ISO 8601). An explicit window overrides include_past.
          schema: { type: string }
        - name: to
          in: query
          description: Upper bound on start time (ISO 8601).
          schema: { type: string }
        - name: include_past
          in: query
          description: When no from/to window is given, include past events.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Event' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Events]
      operationId: createEvent
      summary: Create an event
      description: |
        Routes through the shared Events::Creator, so attendees are notified and the
        event syncs to a connected Google Calendar. Naive times parse in the company
        zone. Requires operation `events:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, starts_at, ends_at]
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                starts_at: { type: string, description: ISO 8601 (e.g. 2026-07-20T14:00). }
                ends_at: { type: string, description: ISO 8601. }
                all_day: { type: boolean, default: false }
                meeting_url: { type: [string, 'null'] }
                attendee_emails:
                  type: array
                  description: Emails of assignable company users to invite.
                  items: { type: string, format: email }
            example:
              name: اجتماع مراجعة التصاميم
              description: نراجع نسخة الهيدر ونقرر الاتجاه النهائي.
              starts_at: '2026-08-05T13:00'
              ends_at: '2026-08-05T14:00'
              all_day: false
              meeting_url: https://meet.google.com/abc-defg-hij
              attendee_emails: [abdullah@example.com]
      responses:
        '201':
          description: The created event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events/{id}:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      operationId: getEvent
      summary: Get an event
      description: Requires operation `events:read`.
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Events]
      operationId: updateEvent
      summary: Update an event
      description: |
        Organizer-only (EventPolicy#update?). Only passed fields change, EXCEPT
        `attendee_emails`, which replaces the whole guest list when present. Requires
        operation `events:write`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                starts_at: { type: string, description: ISO 8601. }
                ends_at: { type: string, description: ISO 8601. }
                all_day: { type: boolean }
                meeting_url: { type: [string, 'null'] }
                attendee_emails:
                  type: array
                  description: When present, REPLACES the entire attendee list.
                  items: { type: string, format: email }
            examples:
              rename_only:
                summary: Change the title and nothing else
                description: >
                  `attendee_emails` is absent, so the guest list is left exactly as it
                  was. Times are untouched too.
                value:
                  name: اجتماع مراجعة التصاميم (نسخة مُحدثة)
              replace_guest_list:
                summary: Replace the whole guest list
                description: >
                  `attendee_emails` is PRESENT, so it is the complete new list. Anyone
                  missing from it is uninvited. Sending an empty array removes everyone.
                value:
                  attendee_emails: [abdullah@example.com, sara@example.com]
      responses:
        '200':
          description: The updated event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Events]
      operationId: deleteEvent
      summary: Delete an event
      description: |
        Permanent, organizer-only. Cascades to personal copies others made of a
        public event. Requires operation `events:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          event:
                            type: object
                            properties:
                              id: { type: integer }
                              name: { type: string }
              example:
                data:
                  deleted: true
                  event: { id: 77, name: اجتماع مراجعة التصاميم }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ────────────────────────── Projects ───────────────────────────
  /projects:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: List accessible projects
      description: |
        Projects the caller can access (owned / public / direct-member /
        team-member), newest first. Excludes archived unless `include_archived`.
        Requires operation `projects:read`.
      parameters:
        - name: include_archived
          in: query
          description: Include archived projects.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects?limit=20&offset=0&include_archived=false" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of projects.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Projects]
      operationId: createProject
      summary: Create a project
      description: Requires operation `projects:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                start_date: { type: [string, 'null'], description: ISO 8601 date. }
                end_date: { type: [string, 'null'], description: ISO 8601 date. }
                is_public: { type: boolean }
            example:
              name: تطوير الموقع
              description: إعادة بناء الموقع التعريفي وربطه بالمنتج.
              start_date: '2026-07-01'
              end_date: '2026-09-30'
              is_public: false
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X POST "$FRQ_BASE/projects" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "name": "Website launch",
                    "description": "Everything before we ship",
                    "start_date": "2026-08-05",
                    "end_date": "2026-09-30",
                    "is_public": true
                  }'
      responses:
        '201':
          description: The created project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectSlugId'
    get:
      tags: [Projects]
      operationId: getProject
      summary: Get a project
      description: Requires operation `projects:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # $PROJECT is the ref returned as `data.id` on create (prj-8f3kd).
            # A slug still works here, but needs percent-encoding when it is Arabic.
            curl -s "$FRQ_BASE/projects/$PROJECT" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Projects]
      operationId: updateProject
      summary: Update a project
      description: Requires operation `projects:write`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                start_date: { type: [string, 'null'] }
                end_date: { type: [string, 'null'] }
                is_public: { type: boolean }
            example:
              description: إعادة بناء الموقع التعريفي وربطه بالمنتج وبلوحة القياس.
              end_date: '2026-10-15'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X PATCH "$FRQ_BASE/projects/$PROJECT" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{"description": "Scope agreed with the client"}'
      responses:
        '200':
          description: The updated project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Projects]
      operationId: deleteProject
      summary: Delete a project
      description: |
        Permanent, cascading delete (demands `manage_projects`). Prefer archive.
        The response reports the counts destroyed. Requires operation
        `projects:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          project:
                            type: object
                            properties:
                              name: { type: string }
                              id: { type: string, description: The project ref. }
                              slug: { type: string }
                          destroyed:
                            type: object
                            properties:
                              task_lists: { type: integer }
                              tasks: { type: integer }
                              discussions: { type: integer }
                              files: { type: integer }
                              folders: { type: integer }
              example:
                data:
                  deleted: true
                  project: { name: تطوير الموقع, id: prj-8f3kd, slug: تطوير-الموقع }
                  destroyed: { task_lists: 4, tasks: 27, discussions: 3, files: 12, folders: 2 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{id}/archive:
    parameters:
      - $ref: '#/components/parameters/ProjectSlugId'
    post:
      tags: [Projects]
      operationId: archiveProject
      summary: Archive a project
      description: |
        Reversible put-away; records a timeline event and freezes writes across the
        project. Prefer this over delete. Requires operation `projects:write`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # The reversible put-away. Prefer it to DELETE, which cascades permanently.
            curl -s -X POST "$FRQ_BASE/projects/$PROJECT/archive" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The archived project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{id}/unarchive:
    parameters:
      - $ref: '#/components/parameters/ProjectSlugId'
    post:
      tags: [Projects]
      operationId: unarchiveProject
      summary: Unarchive a project
      description: Requires operation `projects:write`.
      responses:
        '200':
          description: The reactivated project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ────────────────────────── Members ────────────────────────────
  /projects/{project_id}/members:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Members]
      operationId: listProjectMembers
      summary: List a project's assignable people
      description: |
        The people who can be assigned work on a project — the emails
        `assignee_email` (tasks) and `attendee_emails` (events) resolve against.
        Not paginated. Requires operation `projects:read`.
      responses:
        '200':
          description: The assignable members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Member' }
              example:
                data:
                  - { name: سارة العتيبي, email: sara@example.com, role: مدير }
                  - { name: عبدالله المطيري, email: abdullah@example.com, role: عضو فريق }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────── Task lists ──────────────────────────
  /projects/{project_id}/lists:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Task lists]
      operationId: listTaskLists
      summary: List a project's task lists
      description: Requires operation `task_lists:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$PROJECT/lists" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of task lists.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/TaskList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Task lists]
      operationId: createTaskList
      summary: Create a task list
      description: Requires operation `task_lists:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                notes: { type: [string, 'null'], description: Rich-text notes (plain text in). }
                priority: { $ref: '#/components/schemas/Priority' }
            example:
              title: الصفحة الرئيسية
              notes: نبدأ بالهيدر ثم قسم المزايا.
              priority: high
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X POST "$FRQ_BASE/projects/$PROJECT/lists" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "title": "Before launch",
                    "notes": "Everything that must close before we publish",
                    "priority": "high"
                  }'
      responses:
        '201':
          description: The created task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    get:
      tags: [Task lists]
      operationId: getTaskList
      summary: Get a task list
      description: Requires operation `task_lists:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$PROJECT/lists/$LIST" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Task lists]
      operationId: updateTaskList
      summary: Update a task list
      description: Requires operation `task_lists:write`.
      requestBody: { $ref: '#/components/requestBodies/UpdateTaskList' }
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X PATCH "$FRQ_BASE/projects/$PROJECT/lists/$LIST" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{"priority": "urgent"}'
      responses:
        '200':
          description: The updated task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Task lists]
      operationId: deleteTaskList
      summary: Delete a task list
      description: |
        Permanent, cascading (destroys its tasks). Demands
        `delete_any_task_or_list`, no creator bypass. Requires operation
        `task_lists:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedTaskList' }
              example:
                data:
                  deleted: true
                  task_list: { title: الصفحة الرئيسية, id: lst-4m2qp, slug: الصفحة-الرئيسية }
                  deleted_tasks: 9
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{id}/complete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    post:
      tags: [Task lists]
      operationId: completeTaskList
      summary: Mark a task list complete
      description: |
        Open to any project member (a workflow action, not a content edit).
        Requires operation `task_lists:write`.
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{id}/incomplete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    post:
      tags: [Task lists]
      operationId: reopenTaskList
      summary: Reopen a task list
      description: Open to any project member. Requires operation `task_lists:write`.
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{id}/move:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    post:
      tags: [Task lists]
      operationId: moveTaskList
      summary: Move a task list to another project
      description: |
        Moves the list (with its tasks) into another project. The caller must be
        allowed to create a list in the destination (refuses an archived target).
        Tasks assigned to a non-member of the target are unassigned. Requires
        operation `task_lists:write`.
      requestBody: { $ref: '#/components/requestBodies/MoveTaskList' }
      responses:
        '200':
          description: The moved task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{task_list_id}/comments:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
    get:
      tags: [Comments]
      operationId: listTaskListComments
      summary: List a task list's comments
      description: Requires operation `comments:read`. Oldest first.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$PROJECT/lists/$LIST/comments?limit=100" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of comments.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentCollection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Comments]
      operationId: createTaskListComment
      summary: Comment on a task list
      description: Requires operation `comments:write`.
      requestBody: { $ref: '#/components/requestBodies/CreateComment' }
      responses:
        '201':
          description: The created comment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ──────────────────────────── Tasks ────────────────────────────
  /projects/{project_id}/lists/{task_list_id}/tasks:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
    get:
      tags: [Tasks]
      operationId: listTasks
      summary: List a task list's tasks
      description: Requires operation `tasks:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks?limit=100" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of tasks.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskCollection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Tasks]
      operationId: createTask
      summary: Create a task
      description: |
        Creates a task in the list. `assignee_email` must name an assignable member
        (see the project's members). Requires operation `tasks:write`.
      requestBody: { $ref: '#/components/requestBodies/CreateTask' }
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # assignee_email must name a member of THIS project (or the project must be
            # public), otherwise the call is a 422 naming the address. Omit it to leave
            # the task unassigned.
            curl -s -X POST "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "title": "Proofread the homepage",
                    "notes": "Copy edit plus terminology pass",
                    "due_at": "2026-08-20",
                    "assignee_email": "sara@example.com"
                  }'
      responses:
        '201':
          description: The created task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{task_list_id}/tasks/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    get:
      tags: [Tasks]
      operationId: getTask
      summary: Get a task
      description: Requires operation `tasks:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks/$TASK" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Tasks]
      operationId: updateTask
      summary: Update a task
      description: |
        Only passed fields change. Passing `assignee_email` (even empty) reassigns;
        an empty value unassigns. Requires operation `tasks:write`.
      requestBody: { $ref: '#/components/requestBodies/UpdateTask' }
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # An empty assignee_email unassigns; omitting the key leaves the assignee alone.
            curl -s -X PATCH "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks/$TASK" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{"due_at": "2026-08-24", "assignee_email": ""}'
      responses:
        '200':
          description: The updated task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Tasks]
      operationId: deleteTask
      summary: Delete a task
      description: |
        Permanent. Demands `delete_any_task_or_list`, no creator bypass. Requires
        operation `tasks:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedTask' }
              example:
                data:
                  deleted: true
                  task: { title: تصميم الهيدر, id: tsk-9wb7t, slug: تصميم-الهيدر }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{task_list_id}/tasks/{id}/complete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    post:
      tags: [Tasks]
      operationId: completeTask
      summary: Mark a task complete
      description: |
        Open to any project member (a workflow action). Idempotent — completing an
        already-complete task is a no-op. Requires operation `tasks:write`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # Idempotent: completing an already-complete task is a no-op 200.
            curl -s -X POST "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks/$TASK/complete" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{task_list_id}/tasks/{id}/incomplete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    post:
      tags: [Tasks]
      operationId: reopenTask
      summary: Reopen a task
      description: Open to any project member. Idempotent. Requires operation `tasks:write`.
      responses:
        '200':
          description: The task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{task_list_id}/tasks/{id}/move:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    post:
      tags: [Tasks]
      operationId: moveTask
      summary: Move a task to another list in the same project
      description: |
        Moves the task to a different list in the SAME project (appended to the
        end). The destination's edit permission is re-checked. Requires operation
        `tasks:write`.
      requestBody: { $ref: '#/components/requestBodies/MoveTask' }
      responses:
        '200':
          description: The moved task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/lists/{task_list_id}/tasks/{task_id}/comments:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskId'
    get:
      tags: [Comments]
      operationId: listTaskComments
      summary: List a task's comments
      description: Requires operation `comments:read`. Oldest first.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$PROJECT/lists/$LIST/tasks/$TASK/comments?limit=100" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of comments.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentCollection' }
              # The whole thread, in the order the endpoint returns it: oldest
              # first, several authors, one comment carrying an attachment the
              # plain-text `content` cannot show. A single-row example would hide
              # both the ordering and the reason `attachments` exists.
              example:
                data:
                  - id: 512
                    content: راجعت النسخة الأخيرة، ينقصنا حالة الخطأ في النموذج.
                    author: عبدالله المطيري
                    created_at: '2026-07-15T09:48:03.000+03:00'
                    updated_at: '2026-07-15T09:48:03.000+03:00'
                    attachments: []
                  - id: 518
                    content: >-
                      راجعت الهيدر على ثلاثة مقاسات، وعندي ثلاث ملاحظات. رفعت لقطة
                      للحالات الثلاث.
                    author: نورة الحربي
                    created_at: '2026-07-15T11:20:41.000+03:00'
                    updated_at: '2026-07-15T11:34:09.000+03:00'
                    attachments: [الحالات-الثلاث.png]
                  - id: 524
                    content: >-
                      خذوا ملاحظات نورة كلها، وابدأوا بالثانية لأنها تكسر
                      الاستخدام لا الشكل. نراجع الاثنين القادم.
                    author: سارة العتيبي
                    created_at: '2026-07-15T14:05:12.000+03:00'
                    updated_at: '2026-07-15T14:05:12.000+03:00'
                    attachments: []
                meta: { total: 3, limit: 50, offset: 0, count: 3 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Comments]
      operationId: createTaskComment
      summary: Comment on a task
      description: Requires operation `comments:write`.
      requestBody: { $ref: '#/components/requestBodies/CreateComment' }
      responses:
        '201':
          description: The created comment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
              # A task's comments are a conversation between several people, and a
              # review comment is a paragraph, not a sentence. These variants show
              # both, so a caller sizes its UI for what the endpoint really returns.
              examples:
                short:
                  summary: A quick reply
                  value:
                    data:
                      id: 512
                      content: راجعت النسخة الأخيرة، ينقصنا حالة الخطأ في النموذج.
                      author: عبدالله المطيري
                      created_at: '2026-07-15T09:48:03.000+03:00'
                      updated_at: '2026-07-15T09:48:03.000+03:00'
                      attachments: []
                review:
                  summary: A long review comment from another member
                  value:
                    data:
                      id: 518
                      content: >-
                        راجعت الهيدر على ثلاثة مقاسات، وعندي ثلاث ملاحظات.


                        الأولى أن الشعار يقفز سطراً كاملاً تحت 380 بكسل لأن العنصر
                        المجاور له لا ينكمش، فيظهر فراغ أبيض في أعلى الصفحة على
                        أجهزة قديمة ما زالت تمثل نسبة معتبرة من زوارنا.


                        الثانية أن القائمة المنسدلة تفتح لليسار في الوضع العربي،
                        وهذا يخالف اتجاه القراءة ويجعلها تخرج خارج الشاشة.


                        الثالثة بسيطة: وزن الخط في الروابط 500 بينما بقية الموقع
                        على 400، فيبدو الهيدر أثقل مما حوله. رفعت لقطة للحالات
                        الثلاث في مجلد «التصاميم».
                      author: نورة الحربي
                      created_at: '2026-07-15T11:20:41.000+03:00'
                      updated_at: '2026-07-15T11:20:41.000+03:00'
                      attachments: [الحالات-الثلاث.png]
                decision:
                  summary: A closing note from the project owner
                  value:
                    data:
                      id: 524
                      content: >-
                        خذوا ملاحظات نورة كلها، وابدأوا بالثانية لأنها تكسر
                        الاستخدام لا الشكل. نراجع الاثنين القادم.
                      author: سارة العتيبي
                      created_at: '2026-07-15T14:05:12.000+03:00'
                      updated_at: '2026-07-15T14:05:12.000+03:00'
                      attachments: []
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────── Discussions ─────────────────────────
  /projects/{project_id}/discussions:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Discussions]
      operationId: listDiscussions
      summary: List a project's majlis topics
      description: Requires operation `discussions:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of discussions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Discussion' }
              # A real majlis board: several authors, bodies of the length people
              # actually write, and a topic whose decision points at an uploaded
              # file. A one-line example from a single author teaches a shape the
              # caller will never receive.
              example:
                data:
                  - id: mjl-2wq7d
                    title: 'قرار: توحيد هوية التقارير المُصدَّرة'
                    body: >-
                      اتفقنا في اجتماع الاثنين على أن كل تقرير يخرج من فريقي يحمل
                      هوية الشركة نفسها: الشعار في الترويسة، ولون العناوين
                      #00674F، والتاريخ بالهجري والميلادي معاً في الصفحة الأولى.


                      السبب أن التقارير التي أرسلناها للمستثمرين الربع الماضي خرجت
                      بثلاثة أشكال مختلفة لأن كل واحد منا صدّرها من شاشة مختلفة،
                      فبدت وكأنها من ثلاث شركات.


                      التنفيذ على فريق التصميم قبل نهاية الشهر، والمرجع ملف الهوية
                      المرفق في مجلد «الهوية البصرية». من عنده اعتراض فليكتبه هنا
                      قبل الأحد، وبعدها يصير القرار نافذاً.
                    category: قرار
                    author: سارة العتيبي
                    replies_count: 7
                    created_at: '2026-07-21T09:12:44.000+03:00'
                  - id: mjl-6khz2
                    title: اقتراح لتحسين صفحة التسعير
                    body: >-
                      أقترح نعرض الباقات في جدول مقارنة واحد بدل البطاقات المنفصلة.


                      لاحظت من تسجيلات الجلسات أن الزائر يفتح البطاقات الأربع
                      واحدة بعد الأخرى ثم يعود للأعلى، وهذا يعني أنه يحاول المقارنة
                      ولا تسعفه الواجهة. الجدول يحل هذا في نظرة واحدة.
                    category: فكرة
                    author: عبدالله المطيري
                    replies_count: 4
                    created_at: '2026-07-08T14:02:55.000+03:00'
                  - id: mjl-9nc4t
                    title: إجازة فريق التطوير الأسبوع القادم
                    body: >-
                      فريق التطوير في إجازة من الأحد إلى الثلاثاء. أي طلب عاجل
                      يُرفع هنا وسنتابعه، وما عدا ذلك يُجدول بعد العودة.
                    category: إعلان
                    author: نورة الحربي
                    replies_count: 2
                    created_at: '2026-07-02T08:30:10.000+03:00'
                meta: { total: 3, limit: 50, offset: 0, count: 3 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Discussions]
      operationId: createDiscussion
      summary: Create (and publish) a majlis topic
      description: |
        Creates and PUBLISHES the topic — subscribing its audience, notifying them,
        and recording the timeline event. Requires operation `discussions:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                body: { type: [string, 'null'], description: Rich-text body (plain text in). }
            example:
              title: اقتراح لتحسين صفحة التسعير
              body: أقترح نعرض الباقات في جدول مقارنة واحد بدل البطاقات المنفصلة.
      responses:
        '201':
          description: The created topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/discussions/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/DiscussionSlugId'
    get:
      tags: [Discussions]
      operationId: getDiscussion
      summary: Get a majlis topic
      description: Requires operation `discussions:read`.
      responses:
        '200':
          description: The topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Discussions]
      operationId: updateDiscussion
      summary: Update a majlis topic
      description: Requires operation `discussions:write`.
      requestBody: { $ref: '#/components/requestBodies/UpdateDiscussion' }
      responses:
        '200':
          description: The updated topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Discussions]
      operationId: deleteDiscussion
      summary: Delete a majlis topic
      description: |
        SOFT delete (recoverable by an admin). Requires operation
        `discussions:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedDiscussion' }
              example:
                data:
                  deleted: true
                  recoverable: true
                  discussion: { title: اقتراح لتحسين صفحة التسعير, id: mjl-6khz2, slug: اقتراح-لتحسين-صفحة }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/discussions/{id}/reply:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/DiscussionSlugId'
    post:
      tags: [Discussions]
      operationId: replyToDiscussion
      summary: Reply to a majlis topic
      description: |
        Adds a reply. The reply is serialized with the same shape as a Comment.
        Requires operation `discussions:write`.
      requestBody: { $ref: '#/components/requestBodies/DiscussionReply' }
      responses:
        '201':
          description: The created reply.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ──────────────────────────── Files ────────────────────────────
  /projects/{project_id}/files:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Files]
      operationId: listFiles
      summary: List one level of a project's file browser
      description: |
        The folders and files directly inside `folder_id` (or the project root).
        Not recursive — walk down by passing a subfolder's id back. Files are
        paginated; folders are not. Requires operation `files:read`.
      parameters:
        - name: folder_id
          in: query
          description: The folder to list inside; omit for the project root.
          schema: { type: integer }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One level of the file browser.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      folder:
                        oneOf:
                          - $ref: '#/components/schemas/Folder'
                          - type: 'null'
                        description: The folder being listed, or null at the project root.
                      folders:
                        type: array
                        items: { $ref: '#/components/schemas/Folder' }
                      files:
                        type: array
                        items: { $ref: '#/components/schemas/ProjectFile' }
                  meta:
                    type: object
                    properties:
                      total: { type: integer, description: Total files in this folder (folders are not paginated). }
                      limit: { type: integer }
                      offset: { type: integer }
              example:
                data:
                  folder:
                    id: 42
                    name: التصاميم
                    path: التصاميم
                    description: ملفات الهوية وواجهات الموقع.
                    parent_folder_id: null
                    created_at: '2026-07-05T08:20:00.000+03:00'
                  folders:
                    - id: 51
                      name: الأيقونات
                      path: التصاميم/الأيقونات
                      description: null
                      parent_folder_id: 42
                      created_at: '2026-07-06T09:02:14.000+03:00'
                  files:
                    - id: 108
                      name: الهوية-البصرية.pdf
                      path: التصاميم/الهوية-البصرية.pdf
                      folder_id: 42
                      content_type: application/pdf
                      size_bytes: 2418176
                      size_human: '2.31 م.بايت'
                      external_url: null
                      source: null
                      scan_state: clean
                      downloadable: true
                      uploaded_by: سارة العتيبي
                      created_at: '2026-07-10T16:31:44.000+03:00'
                    - id: 131
                      name: واجهات الصفحة الرئيسية
                      path: التصاميم/واجهات الصفحة الرئيسية
                      folder_id: 42
                      content_type: null
                      size_bytes: null
                      size_human: null
                      external_url: https://www.figma.com/design/9aZq/home-v3
                      source: Figma
                      scan_state: skipped
                      downloadable: true
                      uploaded_by: سارة العتيبي
                      created_at: '2026-07-12T11:04:20.000+03:00'
                    # Media uploads: an image and a video, so a caller can see what
                    # content_type / size_bytes / scan_state look like on real binaries
                    # rather than only on a PDF and an external link. The video is
                    # mid-scan, which is the one state that flips `downloadable`.
                    - id: 147
                      name: لقطة-الصفحة-الرئيسية.png
                      path: التصاميم/لقطة-الصفحة-الرئيسية.png
                      folder_id: 42
                      content_type: image/png
                      size_bytes: 884736
                      size_human: '864 ك.بايت'
                      external_url: null
                      source: null
                      scan_state: clean
                      downloadable: true
                      uploaded_by: عبدالله المطيري
                      created_at: '2026-07-14T10:22:07.000+03:00'
                    - id: 152
                      name: عرض-التدفق-الجديد.mp4
                      path: التصاميم/عرض-التدفق-الجديد.mp4
                      folder_id: 42
                      content_type: video/mp4
                      size_bytes: 41943040
                      size_human: '40 م.بايت'
                      external_url: null
                      source: null
                      scan_state: pending
                      downloadable: false
                      uploaded_by: نورة الحربي
                      created_at: '2026-07-15T13:47:58.000+03:00'
                meta: { total: 4, limit: 50, offset: 0 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Files]
      operationId: uploadFile
      summary: Upload a file from inline content
      description: |
        Uploads a file from inline `content` (UTF-8 text) OR `content_base64`
        (binary, standard base64, no line breaks) — exactly one, not both. Capped at
        5 MB decoded. A freshly uploaded file is `pending` (not downloadable) until
        the virus scan clears it. Requires operation `files:write`.

        **`content` and the file name have to agree.** The name is a claim about
        what the bytes are, so `content` is accepted only for a plain-text
        extension — documents (`md`, `txt`, `rst`, `tex`), data and config
        (`csv`, `json`, `yml`, `xml`, `toml`, `ini`, `conf`, `env`), markup and
        styles (`html`, `svg`, `css`, `scss`), source (`js`, `ts`, `rb`, `py`,
        `sh`, `sql`, `graphql`) and the rest of that kind — or a name with no
        extension at all. A `.pdf` name is the one exception: the `content` is
        read as markdown and rendered into a real PDF document, capped at 256 KB
        of markdown because the render holds a request thread for its whole
        duration. Any other extension is refused with a 422 rather than stored,
        because text under a container extension such as `.docx` produces a file
        no viewer can open. Send genuine bytes as `content_base64` instead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, description: The file name (with extension). }
                folder_id: { type: [integer, 'null'], description: Destination folder; omit for the project root. }
                content: { type: [string, 'null'], description: UTF-8 text content. Provide this OR content_base64. }
                content_base64: { type: [string, 'null'], description: Base64-encoded bytes. Provide this OR content. }
            examples:
              text_upload:
                summary: UTF-8 text, straight into the project root
                value:
                  name: ملاحظات-الإطلاق.md
                  content: |
                    # ملاحظات الإطلاق
                    راجعنا الهيدر وقسم المزايا.
              binary_upload:
                summary: Binary bytes into a folder
                description: Standard base64, no line breaks. Capped at 5 MB decoded.
                value:
                  name: الهوية-البصرية.pdf
                  folder_id: 42
                  content_base64: JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c+PgplbmRvYmoK
      responses:
        '201':
          description: The uploaded file.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectFileEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/files/link:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Files]
      operationId: addExternalLink
      summary: Add an external link
      description: |
        Adds an external link (Figma, Drive, Notion...) — no storage, no scan, no
        size limit. The URL's domain is validated against `source_type`. Requires
        operation `files:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url, source_type]
              properties:
                name: { type: string }
                url: { type: string, format: uri }
                source_type: { $ref: '#/components/schemas/ExternalSourceType' }
                folder_id: { type: [integer, 'null'], description: Destination folder; omit for the project root. }
            examples:
              figma:
                summary: A Figma design file
                value:
                  name: واجهات الصفحة الرئيسية
                  url: https://www.figma.com/design/9aZq/home-v3
                  source_type: figma
                  folder_id: 42
              notion:
                summary: A Notion page
                description: >
                  The URL's domain is validated against `source_type`, so a notion.so
                  link declared as `google_drive` is a 422, not a silent mislabel.
                value:
                  name: خطة المحتوى
                  url: https://www.notion.so/fareeqy/content-plan-q3
                  source_type: notion
      responses:
        '201':
          description: The created link.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectFileEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/files/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/FileId'
    delete:
      tags: [Files]
      operationId: deleteFile
      summary: Delete a file
      description: |
        Permanent. You may delete your OWN upload; deleting someone else's needs
        `delete_any_file`. Requires operation `files:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedFile' }
              example:
                data:
                  deleted: true
                  file: { id: 108, name: الهوية-البصرية.pdf }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────── Folders ───────────────────────────
  /projects/{project_id}/folders:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Folders]
      operationId: createFolder
      summary: Create a folder
      description: |
        Creates a folder (an upload-level right, `manage_files`). Requires operation
        `files:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                parent_folder_id: { type: [integer, 'null'], description: Parent folder; omit for the project root. }
            example:
              name: التصاميم
              description: ملفات الهوية وواجهات الموقع.
              parent_folder_id: null
      responses:
        '201':
          description: The created folder.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FolderEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/folders/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/FolderId'
    delete:
      tags: [Folders]
      operationId: deleteFolder
      summary: Delete a folder
      description: |
        Permanent, RECURSIVE — the folder and its entire subtree (every subfolder
        and file, whoever uploaded them). Demands `delete_any_file`, no creator
        bypass. Requires operation `files:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          folder:
                            type: object
                            properties:
                              id: { type: integer }
                              name: { type: string }
                          destroyed:
                            type: object
                            properties:
                              files: { type: integer }
                              subfolders: { type: integer }
              example:
                data:
                  deleted: true
                  folder: { id: 42, name: التصاميم }
                  destroyed: { files: 7, subfolders: 1 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }


  # ═════════════════ Flat, ref-addressed ═════════════════
  #
  # The same operations as above, reached by the record's ref alone.
  # /tasks/tsk-9wb7t instead of
  # /projects/{project_id}/lists/{task_list_id}/tasks/{id}.
  #
  # Every one of these is the SAME operation as its nested twin, sharing its
  # request body and its response schema through components. Only the address
  # differs, so nothing here can describe a different behaviour by drifting.
  #
  # Use these. The nested forms remain because they are the published contract
  # and removing an address is a breaking change, but three identifiers to name
  # one record is three lookups an agent has to have made already, and it cannot
  # make them from a ref it was handed.
  #
  # REF ONLY, and that is the reason this is safe rather than a preference. A
  # task-list or task SLUG is unique inside its parent and repeats across
  # parents, so a flat lookup by slug hands over another project's record. The
  # ref is unique across the install. Each address is pinned to its own prefix,
  # so /tasks/lst-4m2qp is a 404 and never a task list served from the task
  # address; the controller checks the class again after resolving.
  /lists/{id}:
    parameters:
      - $ref: '#/components/parameters/TaskListRef'
    get:
      tags: [Task lists]
      operationId: getTaskListByRef
      summary: Get a task list by ref
      description: Requires operation `task_lists:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/lists/lst-4m2qp" -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Task lists]
      operationId: updateTaskListByRef
      summary: Update a task list by ref
      description: Only passed fields change. Requires operation `task_lists:write`.
      requestBody: { $ref: '#/components/requestBodies/UpdateTaskList' }
      responses:
        '200':
          description: The updated task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Task lists]
      operationId: deleteTaskListByRef
      summary: Delete a task list by ref
      description: |
        Permanent, cascading (destroys its tasks). Demands
        `delete_any_task_or_list`, no creator bypass. Requires operation
        `task_lists:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedTaskList' }
              example:
                data:
                  deleted: true
                  task_list: { title: الصفحة الرئيسية, id: lst-4m2qp, slug: الصفحة-الرئيسية }
                  deleted_tasks: 9
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /lists/{id}/complete:
    parameters:
      - $ref: '#/components/parameters/TaskListRef'
    post:
      tags: [Task lists]
      operationId: completeTaskListByRef
      summary: Complete a task list by ref
      description: Idempotent. Requires operation `task_lists:write`.
      responses:
        '200':
          description: The completed task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /lists/{id}/incomplete:
    parameters:
      - $ref: '#/components/parameters/TaskListRef'
    post:
      tags: [Task lists]
      operationId: incompleteTaskListByRef
      summary: Reopen a task list by ref
      description: Idempotent. Requires operation `task_lists:write`.
      responses:
        '200':
          description: The reopened task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /lists/{id}/move:
    parameters:
      - $ref: '#/components/parameters/TaskListRef'
    post:
      tags: [Task lists]
      operationId: moveTaskListByRef
      summary: Move a task list to another project, by ref
      description: |
        Moves the list (with its tasks) into another project. Requires operation
        `task_lists:write`.
      requestBody: { $ref: '#/components/requestBodies/MoveTaskList' }
      responses:
        '200':
          description: The moved task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /lists/{task_list_id}/comments:
    parameters:
      - $ref: '#/components/parameters/TaskListRefParent'
    get:
      tags: [Comments]
      operationId: listTaskListCommentsByRef
      summary: List a task list's comments, by ref
      description: Oldest first. Requires operation `comments:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: The comments.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentCollection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Comments]
      operationId: createTaskListCommentByRef
      summary: Comment on a task list, by ref
      description: Requires operation `comments:write`.
      requestBody: { $ref: '#/components/requestBodies/CreateComment' }
      responses:
        '201':
          description: The created comment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /lists/{task_list_id}/tasks:
    parameters:
      - $ref: '#/components/parameters/TaskListRefParent'
    get:
      tags: [Tasks]
      operationId: listTasksByRef
      summary: List a task list's tasks, by ref
      description: Requires operation `tasks:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: The tasks.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskCollection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Tasks]
      operationId: createTaskByRef
      summary: Create a task in a task list, by ref
      description: Requires operation `tasks:write`.
      requestBody: { $ref: '#/components/requestBodies/CreateTask' }
      responses:
        '201':
          description: The created task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tasks/{id}:
    parameters:
      - $ref: '#/components/parameters/TaskRef'
    get:
      tags: [Tasks]
      operationId: getTaskByRef
      summary: Get a task by ref
      description: |
        The shortest address this API has, and the one a person copies out of the
        browser. Requires operation `tasks:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/tasks/tsk-9wb7t" -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Tasks]
      operationId: updateTaskByRef
      summary: Update a task by ref
      description: |
        Only passed fields change. Passing `assignee_email` (even empty)
        reassigns; an empty value unassigns. Requires operation `tasks:write`.
      requestBody: { $ref: '#/components/requestBodies/UpdateTask' }
      responses:
        '200':
          description: The updated task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Tasks]
      operationId: deleteTaskByRef
      summary: Delete a task by ref
      description: Permanent. Requires operation `tasks:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedTask' }
              example:
                data:
                  deleted: true
                  task: { title: تصميم الهيدر, id: tsk-9wb7t, slug: تصميم-الهيدر }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tasks/{id}/complete:
    parameters:
      - $ref: '#/components/parameters/TaskRef'
    post:
      tags: [Tasks]
      operationId: completeTaskByRef
      summary: Complete a task by ref
      description: Idempotent. Requires operation `tasks:write`.
      responses:
        '200':
          description: The completed task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tasks/{id}/incomplete:
    parameters:
      - $ref: '#/components/parameters/TaskRef'
    post:
      tags: [Tasks]
      operationId: incompleteTaskByRef
      summary: Reopen a task by ref
      description: Idempotent. Requires operation `tasks:write`.
      responses:
        '200':
          description: The reopened task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tasks/{id}/move:
    parameters:
      - $ref: '#/components/parameters/TaskRef'
    post:
      tags: [Tasks]
      operationId: moveTaskByRef
      summary: Move a task to another list, by ref
      description: |
        Moves the task to a different list in the SAME project, appended to the
        end. Requires operation `tasks:write`.
      requestBody: { $ref: '#/components/requestBodies/MoveTask' }
      responses:
        '200':
          description: The moved task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tasks/{task_id}/comments:
    parameters:
      - $ref: '#/components/parameters/TaskRefParent'
    get:
      tags: [Comments]
      operationId: listTaskCommentsByRef
      summary: List a task's comments, by ref
      description: Oldest first. Requires operation `comments:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: The comments.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentCollection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Comments]
      operationId: createTaskCommentByRef
      summary: Comment on a task, by ref
      description: Requires operation `comments:write`.
      requestBody: { $ref: '#/components/requestBodies/CreateComment' }
      responses:
        '201':
          description: The created comment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /discussions/{id}:
    parameters:
      - $ref: '#/components/parameters/DiscussionRef'
    get:
      tags: [Discussions]
      operationId: getDiscussionByRef
      summary: Get a majlis topic by ref
      description: Requires operation `discussions:read`.
      responses:
        '200':
          description: The topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Discussions]
      operationId: updateDiscussionByRef
      summary: Update a majlis topic by ref
      description: Only passed fields change. Requires operation `discussions:write`.
      requestBody: { $ref: '#/components/requestBodies/UpdateDiscussion' }
      responses:
        '200':
          description: The updated topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Discussions]
      operationId: deleteDiscussionByRef
      summary: Delete a majlis topic by ref
      description: SOFT delete (recoverable by an admin). Requires operation `discussions:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedDiscussion' }
              example:
                data:
                  deleted: true
                  recoverable: true
                  discussion: { title: تسعير الباقات, id: mjl-6khz2, slug: تسعير-الباقات }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /discussions/{id}/reply:
    parameters:
      - $ref: '#/components/parameters/DiscussionRef'
    post:
      tags: [Discussions]
      operationId: replyToDiscussionByRef
      summary: Reply to a majlis topic by ref
      description: Requires operation `discussions:write`.
      requestBody: { $ref: '#/components/requestBodies/DiscussionReply' }
      responses:
        '201':
          description: The created reply.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /files/{id}:
    parameters:
      - $ref: '#/components/parameters/FileRef'
    delete:
      tags: [Files]
      operationId: deleteFileByRef
      summary: Delete a file by ref
      description: |
        Permanent. There is no flat `get` or `patch`: a file's listing hangs off
        its project (and folder), and a file has no update on this surface, so
        deleting it is the only thing its own address is for. Requires operation
        `files:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedFile' }
              example:
                data:
                  deleted: true
                  file: { id: 41, name: عقد-الموردين.pdf }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }


components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        An ApiKey presented as a Bearer credential. Keys carry the `frq_api_`
        prefix and are shown once at creation (only their SHA-256 digest is stored).
        Isolated from the MCP server's Personal Access Tokens (`frq_`).


        A key belongs to the **company**, not to a person: it is administered from
        /settings/api_keys by the company owner or anyone granted the
        `manage_api_keys` permission, who can see and revoke every key the company
        has. Its capability is its own `allowed_operations` and its reach is the
        whole company; the person who created it is recorded as a signature and is
        not whose permissions the call runs with.


        So a key stops authenticating (401) for three reasons and no others:
        revocation, expiry, and the API being switched off for the company. It is
        explicitly unaffected by its creator changing role or leaving.

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size (default 50, max 100, clamped).
      schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
    Offset:
      name: offset
      in: query
      description: Number of records to skip (default 0).
      schema: { type: integer, default: 0, minimum: 0 }
    # Every handle below accepts EITHER the record's ref (prj-8f3kd, lst-8f3kd,
    # tsk-8f3kd, mjl-8f3kd, fil-8f3kd) or the identifier it replaced. The ref is
    # what reads and writes hand back in `data.id`, and what a person copies out
    # of the browser address bar; the older shape is accepted so nothing stored
    # against a previous version of this API has to be migrated.
    #
    # Prefer the ref. It is ASCII, so it needs no percent-encoding, and it does
    # not change when the record is renamed — a slug does, and only keeps
    # resolving because retired ones are kept in a history table.
    ProjectSlugId:
      name: id
      in: path
      required: true
      description: The project REF (prj-8f3kd), or its slug (unique within the company).
      schema: { type: string }
    ProjectId:
      name: project_id
      in: path
      required: true
      description: The project REF, or its slug.
      schema: { type: string }
    TaskListSlugId:
      name: id
      in: path
      required: true
      description: The task-list REF (lst-8f3kd), or its slug (unique within its project).
      schema: { type: string }
    TaskListId:
      name: task_list_id
      in: path
      required: true
      description: The task-list REF, or its slug.
      schema: { type: string }
    TaskSlugId:
      name: id
      in: path
      required: true
      description: The task REF (tsk-8f3kd), or its slug (unique within its task list).
      schema: { type: string }
    TaskId:
      name: task_id
      in: path
      required: true
      description: The task REF, or its slug.
      schema: { type: string }
    DiscussionSlugId:
      name: id
      in: path
      required: true
      description: The majlis topic REF (mjl-8f3kd), or its slug (unique within its project).
      schema: { type: string }
    EventId:
      name: id
      in: path
      required: true
      description: The event's NUMERIC id (events have no ref and no slug).
      schema: { type: integer }
    FileId:
      name: id
      in: path
      required: true
      description: The file's REF (fil-8f3kd), or its numeric id. Files never had a slug.
      schema: { type: [string, integer] }
    FolderId:
      name: id
      in: path
      required: true
      description: The folder's NUMERIC id (folders have no slug).
      schema: { type: integer }

    # ── Ref-only handles, for the FLAT addresses ────────────────────────────
    #
    # The nested handles above take either shape. These take a ref and nothing
    # else, and the routes carry the same constraint, because a flat lookup BY
    # SLUG is the leak this whole surface was nested to avoid: task-list and task
    # slugs are unique within their parent and collide across parents.
    #
    # Pinned per PREFIX rather than to the shared ref shape, so /tasks/lst-4m2qp
    # is a 404 rather than a task list answered from the task address.
    TaskListRef:
      name: id
      in: path
      required: true
      description: The task list REF (lst-4m2qp). A slug is not accepted here.
      schema: { type: string, pattern: '^lst-[0-9a-zA-Z]{5,12}$' }
    TaskListRefParent:
      name: task_list_id
      in: path
      required: true
      description: The task list REF (lst-4m2qp). A slug is not accepted here.
      schema: { type: string, pattern: '^lst-[0-9a-zA-Z]{5,12}$' }
    TaskRef:
      name: id
      in: path
      required: true
      description: The task REF (tsk-9wb7t). A slug is not accepted here.
      schema: { type: string, pattern: '^tsk-[0-9a-zA-Z]{5,12}$' }
    TaskRefParent:
      name: task_id
      in: path
      required: true
      description: The task REF (tsk-9wb7t). A slug is not accepted here.
      schema: { type: string, pattern: '^tsk-[0-9a-zA-Z]{5,12}$' }
    DiscussionRef:
      name: id
      in: path
      required: true
      description: The majlis topic REF (mjl-6khz2). A slug is not accepted here.
      schema: { type: string, pattern: '^mjl-[0-9a-zA-Z]{5,12}$' }
    FileRef:
      name: id
      in: path
      required: true
      description: The file REF (fil-3qd8n). A numeric id is not accepted here.
      schema: { type: string, pattern: '^fil-[0-9a-zA-Z]{5,12}$' }

  # Request bodies shared by an operation's nested and flat addresses, so the
  # two can never describe different payloads for one behaviour.
  requestBodies:
    UpdateTaskList:
      required: false
      content:
        application/json:
          schema:
            type: object
            properties:
              title: { type: string }
              notes: { type: [string, 'null'] }
              priority: { $ref: '#/components/schemas/Priority' }
          example:
            priority: urgent
    MoveTaskList:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [target_project_slug]
            properties:
              target_project_slug: { type: string, description: 'Ref or slug of the destination project. Named _slug for compatibility; both shapes resolve.' }
          example:
            target_project_slug: تطبيق-الجوال
    CreateTask:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [title]
            properties:
              title: { type: string }
              notes: { type: [string, 'null'], description: Rich-text notes (plain text in). }
              due_at:
                type: [string, 'null']
                description: >
                  Due DATE (YYYY-MM-DD, e.g. 2026-07-20). A task is due on a day, not at a
                  moment: the column is a `date`, so a value carrying a time is accepted and
                  the time is then dropped. Send `2026-07-20T14:00` and you get `2026-07-20`
                  back.
              assignee_email: { type: [string, 'null'], format: email, description: Email of an assignable member. }
          example:
            title: تصميم الهيدر
            notes: نحتاج نسخة للجوال ونسخة للديسكتوب.
            due_at: '2026-08-10'
            assignee_email: abdullah@example.com
    UpdateTask:
      required: false
      content:
        application/json:
          schema:
            type: object
            properties:
              title: { type: string }
              notes: { type: [string, 'null'] }
              due_at:
                type: [string, 'null']
                description: >
                  New due DATE (YYYY-MM-DD). Any time component is dropped — see the
                  create operation.
              assignee_email:
                type: [string, 'null']
                description: >
                  Email of an assignable member. An empty string unassigns, which is
                  why this carries no `email` format: the empty value is a real,
                  accepted input here.
          examples:
            reschedule:
              summary: Push the due date out
              value:
                due_at: '2026-08-17'
            unassign:
              summary: Take the assignee off
              description: An empty string unassigns. Omitting the key leaves the assignee alone.
              value:
                assignee_email: ''
    MoveTask:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [target_task_list_slug]
            properties:
              target_task_list_slug: { type: string, description: 'Ref or slug of the destination task list (same project). Named _slug for compatibility; both shapes resolve.' }
          example:
            target_task_list_slug: صفحة-التسعير
    UpdateDiscussion:
      required: false
      content:
        application/json:
          schema:
            type: object
            properties:
              title: { type: string }
              body: { type: [string, 'null'] }
          example:
            body: أقترح نعرض الباقات في جدول مقارنة واحد، ونثبت زر التجربة أعلى الصفحة.
    DiscussionReply:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [content]
            properties:
              content: { type: string, description: Rich-text reply body (plain text in). }
          example:
            content: أتفق. الجدول أوضح، بشرط نبقي الأسعار ظاهرة بدون ضغط إضافي.
    CreateComment:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [content]
            properties:
              content: { type: string, description: Rich-text comment body (plain text in). }
          example:
            content: خلصنا الهيدر، باقي قسم المزايا.

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unauthorized, message: Invalid or missing API key. } }
    Forbidden:
      description: >
        Two different refusals share this status, and a client must tell them apart
        by `error.code`. `forbidden` means the key's scope or allowlist does not
        permit this operation, or Pundit denied the action. `plan_upgrade_required`
        means the company's plan carries no API access at all, so no key on it can
        ever succeed and there is nothing to retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            forbidden:
              summary: This key may not perform this operation
              value: { error: { code: forbidden, message: This API key is not permitted to perform this operation. } }
            plan_upgrade_required:
              summary: The company's plan carries no API access
              value:
                error:
                  code: plan_upgrade_required
                  message: 'خطة «الاحترافي» لا تشمل الوصول إلى API. رقِّ إلى «المتطور» أو «الإنتاجي» لتفعيله. — The الاحترافي plan does not include API access. Upgrade to «المتطور» or «الإنتاجي» to enable it.'
    NotFound:
      description: >
        Resource not found or not accessible — also returned for EVERY endpoint when
        the company's `rest_api` feature flag is disabled (the surface is hidden).
        Lookups drill through the URL hierarchy, so another company's record is a
        404 and never a leak.

        A path that matches no route at all answers 404 with the distinct code
        `unknown_endpoint` and echoes the path back, so a mistyped or half-built URL
        is told apart from a record that is missing or out of reach.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: not_found, message: 'Resource not found, or you do not have access to it.' } }
    Unprocessable:
      description: A caller-fixable bad request (validation error, bad date, bad enum).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unprocessable_entity, message: Title can't be blank } }
    Conflict:
      description: A uniqueness/record conflict; retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: conflict, message: 'Could not complete due to a conflict; please retry.' } }
    RateLimited:
      description: >
        Either the company's daily API allowance is spent (`rate_limit_exceeded`),
        or the per-key / per-IP burst throttle of 300 requests per minute fired.
        Both come back after a wait, so `Retry-After` is honest here.


        **The two bodies are not the same shape.** The daily-quota refusal uses the
        standard error envelope. The burst throttle is served by Rack::Attack ahead
        of the application, so its body is a flat `{"error": "<string>"}` with no
        `code`. A client that reads `error.code` has to tolerate `error` being a
        plain string.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. On a spent daily allowance
            this points at the company's next midnight.
          schema: { type: integer }
        X-RateLimit-Limit:
          description: The plan's daily API allowance, or `unlimited`.
          schema: { type: string }
        X-RateLimit-Remaining:
          description: Calls left in today's allowance, or `unlimited`.
          schema: { type: string }
        X-RateLimit-Reset:
          description: Unix timestamp of the next reset (the company's next midnight).
          schema: { type: integer }
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                description: The Rack::Attack burst-throttle body. Flat, with no error code.
                required: [error]
                properties:
                  error: { type: string }
          examples:
            rate_limit_exceeded:
              summary: Today's daily allowance is spent (application envelope)
              value:
                error:
                  code: rate_limit_exceeded
                  message: 'استهلكت رصيد اليوم من طلبات API في خطة «المتطور» (1000 طلب يوميًا). يتجدد الرصيد عند منتصف الليل بتوقيت Asia/Riyadh. — Daily API quota exhausted: the المتطور plan allows 1000 calls per day. It resets at midnight Asia/Riyadh.'
            burst_throttled:
              summary: Over 300 requests in a minute (Rack::Attack body, flat error)
              value: { error: Rate limit exceeded. Please try again later. }

  schemas:

    # ── Error envelope ────────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: A stable machine-readable slug.
              enum: [unauthorized, forbidden, plan_upgrade_required, not_found, unprocessable_entity,
                     conflict, bad_request, rate_limit_exceeded]
            message:
              type: string
              description: A human-readable explanation (may join several validation messages with "; ").

    # ── Shared building blocks ────────────────────────────────────
    CollectionMeta:
      type: object
      properties:
        meta:
          type: object
          properties:
            total: { type: integer, description: The full unfiltered count. }
            limit: { type: integer }
            offset: { type: integer }
            count: { type: integer, description: Records in the returned page. }
      example:
        meta: { total: 4, limit: 50, offset: 0, count: 4 }

    # A delete names the record it removed the same way a read names it: `id` is
    # the ref and `slug` is the legacy coordinate beside it, both read off the
    # record before it went. Neither echoes what you sent — send a historical
    # slug and you still get back the one the record actually carried.
    DeletedBase:
      type: object
      required: [deleted]
      properties:
        deleted: { type: boolean, const: true }

    Priority:
      type: [string, 'null']
      description: Task-list priority.
      enum: [low, medium, high, urgent, null]

    ExternalSourceType:
      type: string
      description: The provider an external link points to (its domain is validated).
      enum: [notion, google_drive, dropbox, onedrive, box, figma, sketch, zoho, other]

    UserRef:
      type: object
      description: A user reference — name for humans, email as the write handle.
      properties:
        name: { type: string }
        email: { type: string, format: email }
      example: { name: عبدالله المطيري, email: abdullah@example.com }

    # ── Resource schemas (mirror Api::V1::Serializers) ────────────
    Account:
      type: object
      description: The calling ApiKey and the company it acts for (GET /me).
      properties:
        key:
          type: object
          properties:
            name: { type: string, description: The label the key was given in the app. }
            access:
              type: string
              enum: [read, write]
              description: The scope ceiling. A `write` key also carries `read`.
            operations:
              type: array
              items: { type: string }
              description: Exactly the operations this key may call. Anything else is a 403.
        company:
          type: object
          properties:
            name: { type: [string, 'null'] }
        created_by:
          type: object
          description: Who created the key. A signature, not the permissions the call runs with.
          properties:
            name: { type: [string, 'null'] }
            email: { type: [string, 'null'], format: email }
      example:
        key:
          name: مزامنة الفوترة
          access: write
          operations: [projects:read, tasks:read, tasks:write]
        company: { name: فريق التقنية }
        created_by: { name: سارة العتيبي, email: sara@example.com }

    Project:
      type: object
      properties:
        id: { type: string, description: 'The project ref: the stable public id. A slug is still accepted anywhere this is passed back.' }
        name: { type: string }
        description: { type: [string, 'null'] }
        is_public: { type: boolean }
        archived: { type: boolean }
        start_date: { type: [string, 'null'], format: date }
        end_date: { type: [string, 'null'], format: date }
        task_lists_count: { type: integer, description: Number of task lists (counter cache). }
        tasks_count: { type: integer, description: Number of tasks across the project's lists. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: prj-8f3kd
        name: تطوير الموقع
        description: إعادة بناء الموقع التعريفي وربطه بالمنتج.
        is_public: false
        archived: false
        start_date: '2026-07-01'
        end_date: '2026-09-30'
        task_lists_count: 4
        tasks_count: 27
        created_at: '2026-07-01T09:15:00.000+03:00'
        updated_at: '2026-07-14T12:22:31.000+03:00'

    TaskList:
      type: object
      properties:
        id: { type: string, description: 'The task-list ref. A slug is still accepted anywhere this is passed back.' }
        title: { type: string }
        notes: { type: [string, 'null'], description: Plain-text rendering of the rich-text notes. }
        priority: { $ref: '#/components/schemas/Priority' }
        completed: { type: boolean }
        completed_at: { type: [string, 'null'], format: date-time }
        tasks_count: { type: integer }
        completed_tasks_count: { type: integer }
        project_slug: { type: string }
        created_at: { type: string, format: date-time }
      example:
        id: lst-4m2qp
        title: الصفحة الرئيسية
        notes: نبدأ بالهيدر ثم قسم المزايا.
        priority: high
        completed: false
        completed_at: null
        tasks_count: 9
        completed_tasks_count: 3
        project_slug: تطوير-الموقع
        created_at: '2026-07-02T10:05:12.000+03:00'

    Task:
      type: object
      properties:
        id: { type: string, description: 'The task ref. A slug is still accepted anywhere this is passed back.' }
        title: { type: string }
        notes: { type: [string, 'null'], description: Plain-text rendering of the rich-text notes. }
        completed: { type: boolean }
        completed_at: { type: [string, 'null'], format: date-time }
        due_at: { type: [string, 'null'], format: date, description: Due DATE (no time component). }
        project_slug: { type: [string, 'null'], description: Null for a personal task. }
        task_list_slug: { type: [string, 'null'], description: Null for a personal task. }
        assignee:
          oneOf:
            - $ref: '#/components/schemas/UserRef'
            - type: 'null'
        created_at: { type: string, format: date-time }
      example:
        id: tsk-9wb7t
        title: تصميم الهيدر
        notes: نحتاج نسخة للجوال ونسخة للديسكتوب.
        completed: false
        completed_at: null
        due_at: '2026-08-10'
        project_slug: تطوير-الموقع
        task_list_slug: الصفحة-الرئيسية
        assignee: { name: عبدالله المطيري, email: abdullah@example.com }
        created_at: '2026-07-03T11:40:09.000+03:00'

    Discussion:
      type: object
      properties:
        id: { type: string, description: 'The majlis topic ref. A slug is still accepted anywhere this is passed back.' }
        title: { type: string }
        body: { type: [string, 'null'], description: Plain-text rendering of the rich-text body. }
        category: { type: [string, 'null'] }
        author: { type: [string, 'null'] }
        replies_count: { type: integer }
        created_at: { type: string, format: date-time }
      example:
        id: mjl-6khz2
        title: اقتراح لتحسين صفحة التسعير
        body: أقترح نعرض الباقات في جدول مقارنة واحد بدل البطاقات المنفصلة.
        category: فكرة
        author: سارة العتيبي
        replies_count: 4
        created_at: '2026-07-08T14:02:55.000+03:00'

    Comment:
      type: object
      description: A comment (on a task list or task) or a majlis reply.
      properties:
        id: { type: integer, description: Numeric id (comments have no slug). }
        content: { type: [string, 'null'], description: Plain-text rendering of the rich-text content. }
        author: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, description: Equal to created_at until the comment is edited. }
        attachments:
          type: array
          description: >
            File names embedded in the comment body. The plain-text `content` drops
            attachments entirely, so without these a comment that refers to a screenshot
            reads as a sentence pointing at nothing. Empty when there are none.
          items: { type: string }
      example:
        id: 512
        content: راجعت النسخة الأخيرة، ينقصنا حالة الخطأ في النموذج.
        author: عبدالله المطيري
        created_at: '2026-07-15T09:48:03.000+03:00'
        updated_at: '2026-07-15T09:48:03.000+03:00'
        attachments: [error-state.png]

    ProjectFile:
      type: object
      properties:
        id: { type: integer, description: Numeric id (files have no slug). }
        name: { type: string }
        path: { type: string, description: The folder path the file lives at. }
        folder_id: { type: [integer, 'null'] }
        content_type: { type: [string, 'null'] }
        size_bytes: { type: [integer, 'null'] }
        size_human: { type: [string, 'null'] }
        external_url: { type: [string, 'null'], description: 'Set for an external link, null for an upload.' }
        source: { type: [string, 'null'], description: The source's display name. }
        scan_state: { type: string, description: 'Virus-scan state (e.g. pending, clean).' }
        downloadable: { type: boolean, description: True once the scan has cleared the file. }
        uploaded_by: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
      example:
        id: 108
        name: الهوية-البصرية.pdf
        path: التصاميم/الهوية-البصرية.pdf
        folder_id: 42
        content_type: application/pdf
        size_bytes: 2418176
        size_human: '2.31 م.بايت'
        external_url: null
        source: null
        scan_state: clean
        downloadable: true
        uploaded_by: سارة العتيبي
        created_at: '2026-07-10T16:31:44.000+03:00'

    Folder:
      type: object
      properties:
        id: { type: integer, description: Numeric id (folders have no slug). }
        name: { type: string }
        path: { type: string }
        description: { type: [string, 'null'] }
        parent_folder_id: { type: [integer, 'null'] }
        created_at: { type: string, format: date-time }
      example:
        id: 42
        name: التصاميم
        path: التصاميم
        description: ملفات الهوية وواجهات الموقع.
        parent_folder_id: null
        created_at: '2026-07-05T08:20:00.000+03:00'

    Event:
      type: object
      properties:
        id: { type: integer, description: Numeric id (events have no slug). }
        name: { type: string }
        description: { type: [string, 'null'] }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        all_day: { type: boolean }
        meeting_url: { type: [string, 'null'] }
        organizer: { type: [string, 'null'], description: Only the organizer may edit or delete. }
        organizer_email: { type: [string, 'null'] }
        attendees:
          type: array
          items: { $ref: '#/components/schemas/UserRef' }
      example:
        id: 77
        name: اجتماع مراجعة التصاميم
        description: نراجع نسخة الهيدر ونقرر الاتجاه النهائي.
        starts_at: '2026-08-05T13:00:00.000+03:00'
        ends_at: '2026-08-05T14:00:00.000+03:00'
        all_day: false
        meeting_url: https://meet.google.com/abc-defg-hij
        organizer: سارة العتيبي
        organizer_email: sara@example.com
        attendees:
          - { name: عبدالله المطيري, email: abdullah@example.com }

    Member:
      type: object
      description: An assignable person on a project.
      properties:
        name: { type: string }
        email: { type: string, format: email, description: The handle write endpoints take. }
        role: { type: string, description: The member's display role name. }
      example: { name: عبدالله المطيري, email: abdullah@example.com, role: عضو فريق }

    SearchResult:
      description: >
        A unified search hit. The shape depends on `type`; each carries the
        slugs/ids the other endpoints take.
      oneOf:
        - $ref: '#/components/schemas/SearchProject'
        - $ref: '#/components/schemas/SearchTaskList'
        - $ref: '#/components/schemas/SearchTask'
        - $ref: '#/components/schemas/SearchFolder'
        - $ref: '#/components/schemas/SearchFile'
        - $ref: '#/components/schemas/SearchDiscussion'
      discriminator:
        propertyName: type
        mapping:
          project: '#/components/schemas/SearchProject'
          task_list: '#/components/schemas/SearchTaskList'
          task: '#/components/schemas/SearchTask'
          folder: '#/components/schemas/SearchFolder'
          file: '#/components/schemas/SearchFile'
          discussion: '#/components/schemas/SearchDiscussion'

    # `id` is the hit's own ref and is the handle to carry into the next call.
    # The *_slug keys are the same coordinates in their older form, kept beside it
    # so nothing reading them by name breaks; every endpoint accepts either.
    # A FOLDER is the one hit without an `id`, because it is the one addressable
    # kind with no ref: folder_id is the only handle its endpoints take. A file
    # has both — `id` is its fil- ref, `file_id` its numeric id — and both resolve.
    SearchProject:
      type: object
      required: [type]
      properties:
        type: { type: string, const: project }
        id: { type: string, description: The project ref. }
        title: { type: string }
        project_slug: { type: string }
        archived: { type: boolean }
      example: { type: project, id: prj-8f3kd, title: تطوير الموقع, project_slug: تطوير-الموقع, archived: false }

    SearchTaskList:
      type: object
      required: [type]
      properties:
        type: { type: string, const: task_list }
        id: { type: string, description: The task-list ref. }
        title: { type: string }
        project_slug: { type: string }
        task_list_slug: { type: string }
        completed: { type: boolean }
      example:
        type: task_list
        id: lst-4m2qp
        title: الصفحة الرئيسية
        project_slug: تطوير-الموقع
        task_list_slug: الصفحة-الرئيسية
        completed: false

    SearchTask:
      type: object
      required: [type]
      properties:
        type: { type: string, const: task }
        id: { type: string, description: The task ref. }
        title: { type: string }
        project_slug: { type: string }
        task_list_slug: { type: string }
        task_slug: { type: string }
        completed: { type: boolean }
      example:
        type: task
        id: tsk-9wb7t
        title: تصميم الهيدر
        project_slug: تطوير-الموقع
        task_list_slug: الصفحة-الرئيسية
        task_slug: تصميم-الهيدر
        completed: false

    SearchFolder:
      type: object
      required: [type]
      properties:
        type: { type: string, const: folder }
        name: { type: string }
        project_slug: { type: string }
        folder_id: { type: integer }
      example: { type: folder, name: التصاميم, project_slug: تطوير-الموقع, folder_id: 42 }

    SearchFile:
      type: object
      required: [type]
      properties:
        type: { type: string, const: file }
        id: { type: string, description: The file ref. }
        name: { type: string }
        project_slug: { type: string }
        file_id: { type: integer }
        folder_id: { type: [integer, 'null'] }
      example:
        type: file
        id: fil-3qd8n
        name: الهوية-البصرية.pdf
        project_slug: تطوير-الموقع
        file_id: 108
        folder_id: 42

    SearchDiscussion:
      type: object
      required: [type]
      properties:
        type: { type: string, const: discussion }
        id: { type: string, description: The majlis topic ref. }
        title: { type: string }
        project_slug: { type: string }
        discussion_slug: { type: string }
      example:
        type: discussion
        id: mjl-6khz2
        title: اقتراح لتحسين صفحة التسعير
        project_slug: تطوير-الموقع
        discussion_slug: اقتراح-لتحسين-صفحة

    # ── Single-resource envelopes ─────────────────────────────────
    AccountEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Account' }
    ProjectEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Project' }
    TaskListEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/TaskList' }
    TaskEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Task' }
    DiscussionEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Discussion' }
    CommentEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Comment' }
    ProjectFileEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/ProjectFile' }
    FolderEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Folder' }
    EventEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Event' }

    # ── Collection envelopes ──────────────────────────────────────
    TaskCollection:
      allOf:
        - $ref: '#/components/schemas/CollectionMeta'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Task' }
    CommentCollection:
      allOf:
        - $ref: '#/components/schemas/CollectionMeta'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Comment' }

    # Deletion payloads, shared by each delete's nested and flat addresses.
    DeletedTaskList:
      type: object
      properties:
        data:
          allOf:
            - $ref: '#/components/schemas/DeletedBase'
            - type: object
              properties:
                task_list:
                  type: object
                  properties:
                    title: { type: string }
                    id: { type: string, description: The task list ref. }
                    slug: { type: string }
                deleted_tasks: { type: integer }
    DeletedTask:
      type: object
      properties:
        data:
          allOf:
            - $ref: '#/components/schemas/DeletedBase'
            - type: object
              properties:
                task:
                  type: object
                  properties:
                    title: { type: string }
                    id: { type: string, description: The task ref. }
                    slug: { type: string }
    DeletedDiscussion:
      type: object
      properties:
        data:
          allOf:
            - $ref: '#/components/schemas/DeletedBase'
            - type: object
              properties:
                recoverable: { type: boolean }
                discussion:
                  type: object
                  properties:
                    title: { type: string }
                    id: { type: string, description: The majlis topic ref. }
                    slug: { type: string }
    DeletedFile:
      type: object
      properties:
        data:
          allOf:
            - $ref: '#/components/schemas/DeletedBase'
            - type: object
              properties:
                file:
                  type: object
                  properties:
                    id: { type: integer }
                    name: { type: string }
