openapi: '3.1.0'
info:
  title: Motionworks API - Workspace
  version: 1.0.0
  description: |
    The Workspace is where saved analytical work lives. **Scenarios** are the unit of work — a saved inventory plan, place selection or Set Dynamics analysis, with its inputs stored as a JSON payload. **Projects** group scenarios; a scenario not filed in any project lives in your **sandbox** (unrelated to the Sandbox pricing tier). Both can be **shared** with other organizations.

    **Who owns what.** Project and scenario operations accept either an org-scoped API key (`X-API-Key: mw_…`) or a signed-in session (`Authorization: Bearer <jwt>`) — one or the other, never both (`400 AMBIGUOUS_AUTH`). A resource created with a session is owned by you as a user; one created with an API key is owned by your organization. Owners can do everything; recipients (people at an organization the resource is shared with) can read, list and clone.

    **Sharing.** A project is private until its owner shares it with one or more organizations; scenarios inside a shared project are visible to those organizations unless a scenario is marked `is_sharing_private`. Sandbox scenarios are shared individually. You cannot share with an organization you belong to. When you **list** projects or scenarios, the shared items you see are those shared with your **active organization** — if you belong to several, switch with the activate endpoint. Reading a single project or scenario by id works through any organization you belong to.

    The three organization endpoints (list, activate, shareable list) are about a person moving between memberships, so they accept a signed-in session only. Every operation here is free. Responses carry no data-provenance metadata; these are management calls.
  x-internal-notes: |
    MA-55 Workspace v1 (handlers: services/platform/src/workspace-*.ts). Ownership/sharing are resource_tags rows (MA-64 / ADR-0036 tag-based permissions, migration 0047), resolved via @mworks/permissions isOwner/resolveAccess. meta omits provenance per spec §8 (ADR-7/TF-93 applies to data products only). Auth revised 2026-08-19: dual-path JWT|X-API-Key for projects/scenarios; JWT-only for org endpoints; X-MW-Anon-Session rejected everywhere. Wire-shape spec: docs/products/workspace-projects-scenarios.md § Auth mode per endpoint.

    Round-2 (2026-09-06 code check): single-resource reads (resolveProjectAccess workspace-projects.ts:79-91, resolveScenarioAccess workspace-scenarios.ts:117-141) build the AccessContext from resolveMemberOfOrgIds = ALL org_members rows of the user (workspace-common.ts:601-607); only the list endpoints scope shared items to resolveActiveOrgId (workspace-common.ts:616-622, fetchVisibleResourceIds :385). For an X-API-Key entity both sets are the key's own org. "Provenance" reworded for customers; sandbox glossed to avoid confusion with the Sandbox pricing tier.
  contact:
    name: Motionworks AI
    url: https://mworks.com
    email: api@mworks.com

servers:
  - url: https://api2.mworks.com/v2
    description: Production

components:
  securitySchemes:
    apiKey:
      type: apiKey
      name: X-API-Key
      in: header
      description: |
        Org-scoped API key (`X-API-Key: mw_…`). Resources you create with a key are owned by your organization.
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      x-internal-notes: Supabase user JWT verified with verifySupabaseJwt.
      description: |
        Signed-in session token (`Authorization: Bearer <jwt>`), as issued when you sign in to the Motionworks console or app. Resources you create with a session are owned by you as a user.

  schemas:
    OrgSummary:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid, description: "The organization's id (UUID)." }
        name: { type: string, description: "The organization's display name." }
      description: "An organization, by id and name."
    Project:
      type: object
      required: [id, name, description, created_at, updated_at, archived_at, access]
      properties:
        id: { type: string, format: uuid, description: "The project's id (UUID)." }
        name: { type: string, description: "The project's name." }
        description: { type: string, nullable: true, description: "Free-text description, or `null`." }
        created_at: { type: string, format: date-time, description: "When the project was created (ISO 8601)." }
        updated_at: { type: string, format: date-time, description: "When the project was last changed (ISO 8601)." }
        archived_at: { type: string, format: date-time, nullable: true, description: "When the project was archived (ISO 8601), or `null` while active." }
        access: { type: string, enum: [owner, recipient], description: "`owner` when you own the project; `recipient` when it is shared with an organization you belong to." }
      description: "A project: a named container for scenarios, with your access level."
    CreateProjectRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 200, description: "Name for the project (1–200 characters)." }
        description: { type: string, maxLength: 2000, description: "Optional description (up to 2,000 characters)." }
      description: A new project.
    UpdateProjectRequest:
      type: object
      description: "Changes to a project. Provide at least one of `name` or `description`."
      properties:
        name: { type: string, minLength: 1, maxLength: 200, description: "New name (1–200 characters)." }
        description: { type: string, maxLength: 2000, nullable: true, description: "New description (up to 2,000 characters), or `null` to clear it." }

    ProjectSharing:
      type: object
      required: [shared_org_ids, shared_by_user_id, shared_at]
      properties:
        shared_org_ids:
          type: array
          items: { type: string, format: uuid }
          description: Ids of the organizations the resource is shared with. Empty when private.
        shared_by_user_id: { type: string, format: uuid, nullable: true, description: "Id of the user or organization that set the current sharing, or `null` when private." }
        shared_at: { type: string, format: date-time, nullable: true, description: "When the current sharing was set (ISO 8601), or `null` when private." }
      description: |
        The current sharing state: the organizations the resource is shared with, who shared it and when. Empty when private.
    UpdateSharingRequest:
      type: object
      required: [shared_org_ids]
      description: |
        The complete new list of organizations to share with. It replaces the previous list; an empty array makes the resource private.

      properties:
        shared_org_ids:
          type: array
          items: { type: string, format: uuid }
          description: |
            Ids of the organizations to share with. Each must be in your shareable-organization list and none may be an organization you belong to.

    ScenarioType:
      type: string
      description: "The kind of analysis the scenario holds: `inventory_plan`, `place_select` or `set_dynamics`."
      x-internal-notes: |
        marketplace deliberately excluded from v1 — no service or OpenAPI spec exists yet for that product (spec §2.5).
      enum: [inventory_plan, place_select, set_dynamics]

    Scenario:
      type: object
      required:
        - id
        - project_id
        - name
        - type
        - description
        - payload
        - is_sharing_private
        - created_at
        - updated_at
        - archived_at
        - access
      properties:
        id: { type: string, format: uuid, description: "The scenario's id (UUID)." }
        project_id: { type: string, format: uuid, nullable: true, description: "The project the scenario belongs to, or `null` for a sandbox scenario." }
        name: { type: string, description: "The scenario's name." }
        type: { $ref: '#/components/schemas/ScenarioType' }
        description: { type: string, nullable: true, description: "Free-text description, or `null`." }
        payload: { type: object, description: "The scenario's contents — the inputs and settings of the analysis — as a JSON object. Its shape depends on `type`; the Workspace stores it as you send it." }
        is_sharing_private: { type: boolean, description: "For scenarios in a project: `true` opts the scenario out of the project's sharing so only the owner sees it. Always `false` for sandbox scenarios, which are shared individually." }
        created_at: { type: string, format: date-time, description: "When the scenario was created (ISO 8601)." }
        updated_at: { type: string, format: date-time, description: "When the scenario was last changed (ISO 8601)." }
        archived_at: { type: string, format: date-time, nullable: true, description: "When the scenario was archived (ISO 8601), or `null` while active." }
        access: { type: string, enum: [owner, recipient], description: "`owner` when you own the scenario; `recipient` when you see it through sharing." }
      description: |
        A scenario: a saved analysis with its inputs, where it lives, how it is shared, and your access level.
    CreateScenarioRequest:
      type: object
      required: [name, type]
      properties:
        name: { type: string, minLength: 1, maxLength: 200, description: "Name for the scenario (1–200 characters)." }
        type: { $ref: '#/components/schemas/ScenarioType' }
        description: { type: string, maxLength: 2000, description: "Optional description (up to 2,000 characters)." }
        payload: { type: object, default: {}, description: "The analysis inputs and settings, as a JSON object. Defaults to `{}`." }
      description: A new scenario.
    UpdateScenarioRequest:
      type: object
      description: Changes to a scenario. Provide at least one field.
      properties:
        name: { type: string, minLength: 1, maxLength: 200, description: "New name (1–200 characters)." }
        description: { type: string, maxLength: 2000, nullable: true, description: "New description (up to 2,000 characters), or `null` to clear it." }
        payload: { type: object, description: "Replacement analysis inputs and settings, as a JSON object." }
        is_sharing_private:
          type: boolean
          description: |
            Opt the scenario out of (`true`) or back into (`false`) its project's sharing. Scenarios in a project only — on a sandbox scenario this returns `400 NOT_PROJECT_SCENARIO`.


    CloneScenarioRequest:
      type: object
      properties:
        target_project_id:
          type: string
          format: uuid
          description: |
            Project to place the copy in. Must be a project you own and not archived. Omit to put the copy in your sandbox.

        name:
          type: string
          minLength: 1
          maxLength: 200
          description: "Name for the copy. Defaults to `Copy of <original name>`."
      description: Where to put the copy and what to call it. Both fields are optional; so is the body.
    MoveScenarioRequest:
      type: object
      required: [target_project_id]
      properties:
        target_project_id:
          type: string
          format: uuid
          nullable: true
          description: |
            Project to move the scenario into — must be a project you own and not archived — or `null` to move it to your sandbox.


      description: Where to move the scenario.
paths:
  /workspace/orgs:
    get:
      operationId: listWorkspaceOrgs
      summary: List your organizations
      description: |
        Returns every organization you are a member of, by id and name. Requires a signed-in session (`Authorization: Bearer <jwt>`); API keys are not accepted here. Free.
      tags: ["Organizations"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Your organization memberships.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/OrgSummary' }
                  meta: { type: object }
        '400':
          description: "Both `Authorization` and `X-API-Key` were sent (`AMBIGUOUS_AUTH`). Send exactly one."
        '401':
          description: |
            Missing or invalid session token, or an API key / anonymous session sent where a session is required (`UNAUTHORIZED`).


  /workspace/orgs/{org_id}/activate:
    post:
      operationId: activateWorkspaceOrg
      summary: Switch your active organization
      description: |
        Makes `org_id` your active organization and deactivates the others. Shares are resolved against the active organization, so this changes which shared projects and scenarios you see. You must be a member of the organization. Requires a signed-in session (`Authorization: Bearer <jwt>`); API keys are not accepted here. Free.
      x-internal-notes: |
        activate_org RPC (migration 0050) flips is_active atomically across the user's org_members rows; P0001 → 404 ORG_NOT_FOUND.
      tags: ["Organizations"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ bearerAuth: [] }]
      parameters:
        - name: org_id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: "The organization's `id` (UUID)."
      responses:
        '204':
          description: Active organization switched. No body.
        '400':
          description: "Both `Authorization` and `X-API-Key` were sent (`AMBIGUOUS_AUTH`). Send exactly one."
        '401':
          description: |
            Missing or invalid session token, or an API key / anonymous session sent where a session is required (`UNAUTHORIZED`).

        '404':
          description: "No such organization, or you are not a member of it (`ORG_NOT_FOUND`)."

  /workspace/sharing/orgs:
    get:
      operationId: listWorkspaceSharingOrgs
      summary: List organizations you can share with
      description: |
        Returns the organizations you may share projects and sandbox scenarios with: organizations not on the free plan that you are not a member of. Only ids and names are returned — never members. Requires a signed-in session (`Authorization: Bearer <jwt>`); API keys are not accepted here. Free.
      x-internal-notes: |
        Composition per spec §12 Q1: organizations.plan != free, minus the caller's own memberships.

        Round-2 (2026-09-06 code check): listShareableOrgs filters organizations?plan=neq.free (workspace-common.ts:324) — any non-free plan value qualifies, not only paid ones.
      tags: ["Organizations"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: The organizations you can share with.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/OrgSummary' }
                  meta: { type: object }
        '400':
          description: "Both `Authorization` and `X-API-Key` were sent (`AMBIGUOUS_AUTH`). Send exactly one."
        '401':
          description: |
            Missing or invalid session token, or an API key / anonymous session sent where a session is required (`UNAUTHORIZED`).


  /workspace/projects:
    get:
      operationId: listWorkspaceProjects
      summary: List projects
      description: |
        Returns the projects you can see, newest first: projects you own (`access: owner`) and projects shared with your active organization (`access: recipient`). Archived projects are hidden unless `archived=true`. Paginated: `meta.pagination` carries `cursor`, `has_more` and `total`. API key or signed-in session. Free.
      tags: ["Projects"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: owned_only, in: query, schema: { type: boolean, default: false }, description: "Return only projects you own." }
        - { name: shared_only, in: query, schema: { type: boolean, default: false }, description: "Return only projects shared with your active organization." }
        - { name: archived, in: query, schema: { type: boolean, default: false }, description: "Include archived items. Default `false` — only active items are returned." }
        - { name: cursor, in: query, schema: { type: string }, description: "Cursor from the previous page's `meta.pagination.cursor`." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: "Results per page (1–100, default 25)." }
      responses:
        '200':
          description: A page of projects.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Project' }
                  meta: { type: object }
        '400':
          description: |
            Invalid query parameter, or both `Authorization` and `X-API-Key` were sent (`AMBIGUOUS_AUTH`). Send exactly one.

        '401':
          description: |
            No credentials, an invalid or expired session token, or an anonymous session (`UNAUTHORIZED`); or an invalid API key (`INVALID_API_KEY`).


    post:
      operationId: createWorkspaceProject
      summary: Create a project
      description: |
        Creates a project owned by you (or by your organization, when you use an API key). New projects are private until you share them. API key or signed-in session. Free.
      tags: ["Projects"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateProjectRequest' }
      responses:
        '201':
          description: Project created.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Project' }
                  meta: { type: object }
        '400':
          description: |
            Invalid body (for example a missing or over-long `name`), or both `Authorization` and `X-API-Key` were sent (`AMBIGUOUS_AUTH`). Send exactly one.

        '401':
          description: |
            No credentials, an invalid or expired session token, or an anonymous session (`UNAUTHORIZED`); or an invalid API key (`INVALID_API_KEY`).
          x-internal-notes: |
            Round-2 (2026-09-06 code check): authenticateCaller (workspace-common.ts:202-265): no header / bad JWT / X-MW-Anon-Session → UNAUTHORIZED; X-API-Key path rethrows @mworks/auth AuthError, whose 401s are all INVALID_API_KEY (packages/auth/src/middleware.ts:153-228). JWT-only ops (authenticateJwtOnly :140-178): X-API-Key alone is also UNAUTHORIZED.

  /workspace/projects/{id}:
    get:
      operationId: getWorkspaceProject
      summary: Read a project
      description: |
        Returns one project, if you own it or it is shared with any organization you belong to. `access` tells you which. API key or signed-in session. Free.
      tags: ["Projects"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The project's `id` (UUID)." }
      responses:
        '200':
          description: The project.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Project' }
                  meta: { type: object }
        '404':
          description: |
            No such project, or you have no access to it (`PROJECT_NOT_FOUND`). The response is the same in both cases.


    patch:
      operationId: updateWorkspaceProject
      summary: Update a project
      description: "Changes a project's `name` and/or `description`. Owner only. API key or signed-in session. Free."

      tags: ["Projects"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The project's `id` (UUID)." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateProjectRequest' }
      responses:
        '200':
          description: The updated project.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Project' }
                  meta: { type: object }
        '400':
          description: |
            Invalid body — for example neither `name` nor `description` provided, or a value over the length limit.

        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such project, or you have no access to it (`PROJECT_NOT_FOUND`). The response is the same in both cases.


    delete:
      operationId: archiveWorkspaceProject
      summary: Archive a project
      description: |
        Archives a project: it disappears from lists unless `archived=true`, and no new scenarios can be created in it. Nothing is deleted. Owner only. API key or signed-in session. Free.
      x-internal-notes: "Soft delete: sets archived_at (and updated_at). Scenarios inside are untouched."
      tags: ["Projects"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The project's `id` (UUID)." }
      responses:
        '204':
          description: Project archived. No body.
        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such project, or you have no access to it (`PROJECT_NOT_FOUND`). The response is the same in both cases.


  /workspace/projects/{id}/sharing:
    get:
      operationId: getWorkspaceProjectSharing
      summary: "Read a project's sharing"
      description: |
        Returns the organizations a project is shared with, who shared it and when. Owner only. API key or signed-in session. Free.
      tags: ["Projects"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The project's `id` (UUID)." }
      responses:
        '200':
          description: "The project's sharing state."
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/ProjectSharing' }
                  meta: { type: object }
        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such project, or you have no access to it (`PROJECT_NOT_FOUND`). The response is the same in both cases.


    patch:
      operationId: updateWorkspaceProjectSharing
      summary: "Set a project's sharing"
      description: |
        Replaces the full list of organizations a project is shared with — send the complete list each time; an empty list makes the project private again. Every id must be an organization you can share with (see *List organizations you can share with*), and none may be one you belong to. Scenarios in the project inherit this sharing unless they are marked `is_sharing_private`. Owner only. API key or signed-in session. Free.
      x-internal-notes: |
        PUT semantics on the shared_with_org tag set: DELETE-all then bulk INSERT (non-transactional; a failure between the two leaves the project temporarily unshared — retry the PATCH).
      tags: ["Projects"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The project's `id` (UUID)." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateSharingRequest' }
      responses:
        '200':
          description: The updated sharing state.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/ProjectSharing' }
                  meta: { type: object }
        '400':
          description: |
            `SELF_SHARE` — one of the ids is an organization you belong to; `ORG_NOT_SHAREABLE` — one of the ids is not in your shareable-organization list; or a validation error.

        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such project, or you have no access to it (`PROJECT_NOT_FOUND`). The response is the same in both cases.


  /workspace/projects/{project_id}/scenarios:
    get:
      operationId: listProjectScenarios
      summary: List scenarios in a project
      description: |
        Returns the scenarios in one project, newest first. The owner sees every scenario; recipients see only those not marked `is_sharing_private`. Archived scenarios are hidden unless `archived=true`. All matching scenarios are returned in one response; `cursor` and `limit` are accepted but not applied today. API key or signed-in session. Free.
      x-internal-notes: |
        Handler reads `archived` directly and does NOT apply cursor/limit (no pagination in the response); the parameters are declared in the contract but currently ignored.

        Round-2 (2026-09-06 code check): confirmed workspace-scenarios.ts:276-288 — reads only `archived`; no decodeCursor/limit, no meta.pagination. Customer text now says the params are accepted but not applied.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: project_id, in: path, required: true, schema: { type: string, format: uuid }, description: "The project's `id` (UUID)." }
        - { name: archived, in: query, schema: { type: boolean, default: false }, description: "Include archived items. Default `false` — only active items are returned." }
        - { name: cursor, in: query, schema: { type: string }, description: "Accepted for forward compatibility; not applied today — all matching scenarios are returned in one response." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: "Accepted for forward compatibility; not applied today — all matching scenarios are returned in one response." }
      responses:
        '200':
          description: "The project's scenarios."
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '404':
          description: |
            No such project, or you have no access to it (`PROJECT_NOT_FOUND`). The response is the same in both cases.


    post:
      operationId: createProjectScenario
      summary: Create a scenario in a project
      description: |
        Creates a scenario inside a project you own. It starts with `is_sharing_private: false`, so it is visible to whoever the project is shared with. The project must not be archived. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: project_id, in: path, required: true, schema: { type: string, format: uuid }, description: "The project's `id` (UUID)." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateScenarioRequest' }
      responses:
        '201':
          description: Scenario created.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '400':
          description: |
            `INVALID_SCENARIO_TYPE` — `type` is not one of the supported kinds; `PROJECT_ARCHIVED` — the project is archived; or a validation error in the body.

        '403':
          description: "You do not own this project (`FORBIDDEN`)."
        '404':
          description: |
            No project with this id (`PROJECT_NOT_FOUND`). A project that exists but you do not own — shared with you or not — returns 403.
          x-internal-notes: |
            Round-2 (2026-09-06 code check): handler fetches the project row without an access check (fetchProjectRow, workspace-scenarios.ts:363) → 404 only when the row is missing; then callerOwnsProject → 403 FORBIDDEN for any existing project the caller does not own (:370-372), visible or not.

  /workspace/scenarios:
    get:
      operationId: listWorkspaceScenarios
      summary: List scenarios
      description: |
        Returns every scenario you can see, newest first: the ones you own (in any project or in your sandbox) plus, through your active organization, scenarios in projects shared with you that are not marked private and sandbox scenarios shared with you directly. Filter by `project_id`, or use `sandbox_only` / `owned_only`. Archived scenarios are hidden unless `archived=true`. Paginated: `meta.pagination` carries `cursor`, `has_more` and `total`. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: project_id, in: query, schema: { type: string, format: uuid }, description: "Return only scenarios in this project." }
        - { name: sandbox_only, in: query, schema: { type: boolean, default: false }, description: "Return only sandbox scenarios (those in no project)." }
        - { name: owned_only, in: query, schema: { type: boolean, default: false }, description: "Return only scenarios you own." }
        - { name: archived, in: query, schema: { type: boolean, default: false }, description: "Include archived items. Default `false` — only active items are returned." }
        - { name: cursor, in: query, schema: { type: string }, description: "Cursor from the previous page's `meta.pagination.cursor`." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: "Results per page (1–100, default 25)." }
      responses:
        '200':
          description: A page of scenarios.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '400':
          description: |
            Invalid query parameter, or both `Authorization` and `X-API-Key` were sent (`AMBIGUOUS_AUTH`). Send exactly one.

        '401':
          description: |
            No credentials, an invalid or expired session token, or an anonymous session (`UNAUTHORIZED`); or an invalid API key (`INVALID_API_KEY`).


    post:
      operationId: createSandboxScenario
      summary: Create a sandbox scenario
      description: |
        Creates a scenario in your sandbox — outside any project (`project_id: null`). It is private until you share it with the scenario sharing endpoint. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateScenarioRequest' }
      responses:
        '201':
          description: Sandbox scenario created.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '400':
          description: |
            `INVALID_SCENARIO_TYPE` — `type` is not one of the supported kinds; or a validation error in the body.


  /workspace/scenarios/{id}:
    get:
      operationId: getWorkspaceScenario
      summary: Read a scenario
      description: |
        Returns one scenario, if you own it or it is visible to you through sharing. `access` tells you which. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The scenario's `id` (UUID)." }
      responses:
        '200':
          description: The scenario.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '404':
          description: |
            No such scenario, or you have no access to it (`SCENARIO_NOT_FOUND`). The response is the same in both cases.


    patch:
      operationId: updateWorkspaceScenario
      summary: Update a scenario
      description: |
        Changes a scenario's `name`, `description`, `payload` and/or `is_sharing_private`. `is_sharing_private` applies only to scenarios in a project (it opts the scenario out of the project's sharing); on a sandbox scenario it returns `400 NOT_PROJECT_SCENARIO`. Owner only. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The scenario's `id` (UUID)." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateScenarioRequest' }
      responses:
        '200':
          description: The updated scenario.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '400':
          description: |
            `NOT_PROJECT_SCENARIO` — `is_sharing_private` was sent for a sandbox scenario; or a validation error in the body.

        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such scenario, or you have no access to it (`SCENARIO_NOT_FOUND`). The response is the same in both cases.


    delete:
      operationId: archiveWorkspaceScenario
      summary: Archive a scenario
      description: |
        Archives a scenario: it disappears from lists unless `archived=true`. Nothing is deleted. Owner only. API key or signed-in session. Free.
      x-internal-notes: "Soft delete: sets archived_at (and updated_at)."
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The scenario's `id` (UUID)." }
      responses:
        '204':
          description: Scenario archived. No body.
        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such scenario, or you have no access to it (`SCENARIO_NOT_FOUND`). The response is the same in both cases.


  /workspace/scenarios/{id}/clone:
    post:
      operationId: cloneWorkspaceScenario
      summary: Clone a scenario
      description: |
        Copies a scenario you own or that is shared with you into a new scenario you own. The copy keeps the original's type, description and payload; it inherits none of the original's sharing — in your sandbox the copy starts private; in a project it follows that project's sharing. Pass `target_project_id` to place the copy in a project you own (it must not be archived), or omit it to put the copy in your sandbox. The body is optional. API key or signed-in session. Free.
      x-internal-notes: |
        Round-2 (2026-09-06 code check): createScenarioRow (workspace-scenarios.ts:307-330) inserts the row without is_sharing_private (column default false) and writes only the owner tag — no shared_with_org tags: a sandbox copy is private, a project copy is visible to the target project's recipients.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The scenario's `id` (UUID)." }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CloneScenarioRequest' }
      responses:
        '201':
          description: The new scenario.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '400':
          description: |
            `TARGET_PROJECT_NOT_OWNED` — you do not own `target_project_id`; `PROJECT_ARCHIVED` — the target project is archived; or a validation error in the body.

        '403':
          description: "Not returned today — a scenario you cannot access returns 404 `SCENARIO_NOT_FOUND` instead."
          x-internal-notes: |
            Round-2 (2026-09-06 code check): handleCloneScenario has no FORBIDDEN branch: resolveScenarioAccess null → 404 SCENARIO_NOT_FOUND (workspace-scenarios.ts:643-650); a non-owned target project is 400 TARGET_PROJECT_NOT_OWNED. Response kept in the contract.
        '404':
          description: |
            No such scenario, or you have no access to it (`SCENARIO_NOT_FOUND`). The response is the same in both cases.


  /workspace/scenarios/{id}/move:
    post:
      operationId: moveWorkspaceScenario
      summary: Move a scenario
      description: |
        Moves a scenario into a project you own, or out to your sandbox (`target_project_id: null`). Either way the scenario's own sharing is reset: `is_sharing_private` becomes `false` and any direct organization shares are removed — inside a project it follows the project's sharing, in the sandbox it starts private. The target project must not be archived. Owner only. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The scenario's `id` (UUID)." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MoveScenarioRequest' }
      responses:
        '200':
          description: The moved scenario.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Scenario' }
                  meta: { type: object }
        '400':
          description: |
            `TARGET_PROJECT_NOT_OWNED` — you do not own `target_project_id`; `PROJECT_ARCHIVED` — the target project is archived; or a validation error in the body.

        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such scenario, or you have no access to it (`SCENARIO_NOT_FOUND`). The response is the same in both cases.


  /workspace/scenarios/{id}/sharing:
    get:
      operationId: getWorkspaceScenarioSharing
      summary: "Read a sandbox scenario's sharing"
      description: |
        Returns the organizations a sandbox scenario is shared with, who shared it and when. Sandbox scenarios only — scenarios in a project follow the project's sharing and return `400 NOT_SANDBOX_SCENARIO`. Owner only. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The scenario's `id` (UUID)." }
      responses:
        '200':
          description: "The scenario's sharing state."
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/ProjectSharing' }
                  meta: { type: object }
        '400':
          description: "The scenario is in a project, not the sandbox (`NOT_SANDBOX_SCENARIO`)."
        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such scenario, or you have no access to it (`SCENARIO_NOT_FOUND`). The response is the same in both cases.


    patch:
      operationId: updateWorkspaceScenarioSharing
      summary: "Set a sandbox scenario's sharing"
      description: |
        Replaces the full list of organizations a sandbox scenario is shared with — send the complete list each time; an empty list makes it private again. Every id must be an organization you can share with, and none may be one you belong to. Sandbox scenarios only (`400 NOT_SANDBOX_SCENARIO` otherwise). Owner only. API key or signed-in session. Free.
      tags: ["Scenarios"]
      x-credit-cost: 0
      x-motionworks-status: production
      security: [{ apiKey: [] }, { bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "The scenario's `id` (UUID)." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateSharingRequest' }
      responses:
        '200':
          description: The updated sharing state.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/ProjectSharing' }
                  meta: { type: object }
        '400':
          description: |
            `NOT_SANDBOX_SCENARIO` — the scenario is in a project; `SELF_SHARE` — one of the ids is an organization you belong to; `ORG_NOT_SHAREABLE` — one of the ids is not in your shareable-organization list; or a validation error.

        '403':
          description: "You can see this resource but do not own it; only the owner can do this (`FORBIDDEN`)."
        '404':
          description: |
            No such scenario, or you have no access to it (`SCENARIO_NOT_FOUND`). The response is the same in both cases.

x-tagGroups:
  - name: Workspace
    tags:
      - Organizations
      - Projects
      - Scenarios
tags:
  - name: Organizations
    description: The organizations you belong to, which one is active, and which organizations you can share with. Signed-in session only. Free.
  - name: Projects
    description: Create, read, update, archive and share projects — named containers for scenarios. API key or signed-in session. Free.
  - name: Scenarios
    description: Create, read, update, archive, clone, move and share scenarios, in a project or in your sandbox. API key or signed-in session. Free.
x-customer-voice: true
