openapi: 3.0.1
info:
  title: Aplas API
  description: >
    REST API for programmatic access to the Aplas v2 data model — workspaces,
    asset types, assets, relations and views.


    ## Which version do I use?


    Aplas v3 serves organizations on the v2 data model. Organizations created
    before the v2 rollout remain on [API v2](https://docs.aplas.com/api), which
    exposes the earlier index / application / integration model. An API key
    works with exactly one of the two: a key for a v2 organization is rejected
    by v2 with a message naming v3, and vice versa. If you are unsure which
    your organization is on, call any v3 endpoint — the error tells you.


    ## Identifiers


    Every resource is addressed by its **slug**, returned as `id`. A slug is
    unique within your organization, is chosen when the resource is created,
    and does not change when the resource is renamed — so it is safe to store
    as a reference. Internal database identifiers are never exposed.


    ## Pagination


    List endpoints are cursor-paginated. Pass `limit` to size a page, and pass
    the `nextCursor` from a response as `cursor` to fetch the following one.
    `nextCursor` is `null` on the last page. Cursors are opaque: treat them as
    strings and do not construct them. Cursor paging is stable while data is
    changing underneath you — resources created mid-walk will not cause a
    skipped or duplicated result, which offset paging cannot promise.


    ## Response shape


    List endpoints return an envelope, `{ "data": [ ... ], "nextCursor": ... }`.
    A single resource is returned bare, with no envelope.


    ## Errors


    Errors carry an HTTP status plus a JSON body of `statusCode`, `error` and
    `message`. A `401` means the request carried no credential or a bad one, and
    carries a `WWW-Authenticate` challenge describing how to authenticate — bare
    when nothing was sent, with `error="invalid_token"` when what was sent did
    not validate. A
    `403` means the key is valid but not permitted the operation — a read-only
    key attempting a write, for example. A `403` deliberately carries no
    challenge: retrying with the same key will not help.
  version: 3.0.0
servers:
  - url: https://api.au.aplas.com/api/v3
    description: Australia region
  - url: https://api.eu.aplas.com/api/v3
    description: Europe region
  - url: https://api.us.aplas.com/api/v3
    description: United States region
security:
  - BearerAuth: []
tags:
  - name: Workspaces
    description: Operations relating to workspaces.
  - name: Asset Types
    description: Operations relating to asset types.
  - name: Views
    description: >
      Operations relating to views — the saved table / matrix / graph / map
      configurations inside a workspace.
  - name: Publications
    description: >
      Operations relating to publications — the organization's public shopfront.
      Org-level, because a publication's slug is unique per organization and
      only the default workspace's views may publish.
  - name: Relationship Types
    description: >
      The registry of edge kinds a workspace may use. Read-only: types arrive
      from the standard template, or are minted automatically when an asset type
      declares a relation field.
  - name: Relations
    description: >
      Operations relating to relations between assets. Endpoints are named by
      `{type, id}` slug pairs; a single relation is addressed by its natural key
      (relationship type + target type + target id) rather than a row id.
  - name: Assets
    description: >
      Operations relating to assets — the rows themselves. Nested under an asset
      type because an asset slug is unique per (workspace, type), not per
      workspace.
paths:
  /workspaces:
    get:
      tags: [Workspaces]
      operationId: listWorkspaces
      summary: List workspaces
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of workspaces.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CursorPage"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Workspace"
        "400":
          description: >
            `limit` is out of range, or `cursor` is not a cursor from a previous
            response. Neither is clamped or ignored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Workspaces]
      operationId: createWorkspace
      summary: Create a workspace
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceCreate"
      responses:
        "201":
          description: The created workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Workspace"
        "400":
          description: Missing or invalid fields.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: >
            The id is already in use, or the organization is at its plan's
            workspace limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /workspaces/{workspaceId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Workspaces]
      operationId: getWorkspace
      summary: Get a workspace
      responses:
        "200":
          description: The workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Workspace"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Workspaces]
      operationId: updateWorkspace
      summary: Update a workspace
      description: >
        `id` is not patchable. The slug is the workspace's identity on this API
        and callers store it, so renaming it would break every reference they
        hold.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WorkspaceUpdate"
      responses:
        "200":
          description: The updated workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Workspace"
        "400":
          description: >
            No recognised field to update, or `name` was supplied empty. The
            create and update paths enforce the same rules.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Workspaces]
      operationId: archiveWorkspace
      summary: Archive a workspace
      description: >
        Archives rather than destroys, and frees the slug for reuse. The
        organization's default workspace cannot be archived.
      responses:
        "204":
          description: Archived.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This is the organization's default workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /workspaces/{workspaceId}/asset-types:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Asset Types]
      operationId: listAssetTypes
      summary: List asset types in a workspace
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of asset types.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CursorPage"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/AssetType"
        "400":
          description: >
            `limit` is out of range, or `cursor` is not a cursor from a previous
            response. Neither is clamped or ignored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Asset Types]
      operationId: createAssetType
      summary: Create an asset type
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetTypeCreate"
      responses:
        "201":
          description: The created asset type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetType"
        "400":
          description: Invalid id or malformed fieldSpecs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The id is already in use in this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /workspaces/{workspaceId}/asset-types/{typeId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
    get:
      tags: [Asset Types]
      operationId: getAssetType
      summary: Get an asset type
      responses:
        "200":
          description: The asset type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetType"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Asset Types]
      operationId: updateAssetType
      summary: Update an asset type
      description: >
        `id` is not patchable, for built-in and custom types alike — the slug is
        referenced from asset data and from studio URLs. Built-in types are
        otherwise editable; only their deletion is refused.


        Removing a fieldSpec, or changing its type, when assets already carry
        values for it is DESTRUCTIVE and is refused with a 409 unless
        `confirmDestructive` is true. The refusal names each affected field and
        how many assets it touches.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetTypeUpdate"
      responses:
        "200":
          description: The updated asset type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetType"
        "400":
          description: >
            Malformed fieldSpecs, an invalid colour, no recognised field to
            update, or a name supplied empty.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            The change would strand existing data and `confirmDestructive` was
            not set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Asset Types]
      operationId: deleteAssetType
      summary: Delete an asset type
      description: >
        Assets of this type are removed with it. Built-in types cannot be
        deleted.
      responses:
        "204":
          description: Deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This is a built-in asset type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /workspaces/{workspaceId}/asset-types/{typeId}/assets:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
    get:
      tags: [Assets]
      operationId: listAssets
      summary: List assets of a type
      description: >
        Cursor-paginated, narrowable by `filter` and `search`.


        Unlike the studio, a filter term this API cannot express is REFUSED with
        a 400 rather than skipped. A dropped term would return the unfiltered
        set presented as filtered — more rows than you asked for, with nothing
        in the response saying a constraint was ignored.


        `search` narrows the set but does NOT order it: rows come back in slug
        order so the cursor stays stable. Relevance is not a stable total order,
        so paging by it would skip or repeat rows.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: filter
          in: query
          required: false
          description: >
            A JSON array of `{field, op, value}` terms, ANDed together. `field`
            is a column name (`name`, `status`, `assetId`, `shortName`,
            `createdAt`, `updatedAt`), `fields.<fieldId>` for a declared field,
            or `relations.<slug>` for a relationship. Ops: `eq`, `neq`, `in`,
            `contains`, `gte`, `lte`, `isEmpty`, `notEmpty` — legal ops depend
            on the field's type. Hidden fields are not filterable.
          schema:
            type: string
          example: '[{"field":"fields.vendor","op":"eq","value":"Acme"}]'
        - name: search
          in: query
          required: false
          description: Full-text search across the asset's indexed content.
          schema:
            type: string
      responses:
        "200":
          description: A page of assets.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CursorPage"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Asset"
        "400":
          description: >
            An unusable `limit`, `cursor`, or `filter` term. A filter naming a
            field that is unknown, undeclared, or hidden returns the same
            message in every case — deliberately, so the error cannot be used to
            discover which fields exist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Assets]
      operationId: createAsset
      summary: Create an asset
      description: >
        Counts against the organization's asset cap. `id` is optional — when
        omitted it is derived from `name`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetCreate"
      responses:
        "201":
          description: The created asset.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "400":
          description: Missing `name`, an invalid `id`, or a field value the type rejects.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            The id is already in use for this type, or the organization is at
            its asset cap. The message names the limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /workspaces/{workspaceId}/asset-types/{typeId}/assets/{assetId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
      - $ref: "#/components/parameters/AssetId"
    get:
      tags: [Assets]
      operationId: getAsset
      summary: Get an asset
      responses:
        "200":
          description: The asset.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Assets]
      operationId: updateAsset
      summary: Update an asset
      description: >
        `id` is not patchable — it is referenced from relations and studio URLs.


        Supply either `fields` (replaces the whole bag) or `patchFields` (merges
        over what is stored), never both: the pair is ambiguous and is refused
        rather than resolved by a precedence you would have to guess.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssetUpdate"
      responses:
        "200":
          description: The updated asset.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Asset"
        "400":
          description: >
            An empty body, an empty `name`, both `fields` and `patchFields`, or
            a field value the type rejects.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Assets]
      operationId: deleteAsset
      summary: Delete an asset
      description: >
        Permanent. Deleting is never blocked by the asset cap — it is one of the
        ways back under it.
      responses:
        "204":
          description: Deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/asset-types/{typeId}/assets/{assetId}/relations:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
      - $ref: "#/components/parameters/AssetId"
    get:
      tags: [Relations]
      operationId: listAssetRelations
      summary: List an asset's relations
      description: >
        Both directions, distinguished by `direction`. Relations PROJECTED from
        an asset's own fields (integration endpoints, collection members,
        relation-field mirrors) are listed with `projected: true` — they are
        real relations, but they cannot be written through this API because
        their handler rebuilds them on the projecting asset's next save.


        Cursor-paginated, like every other list here: a highly connected asset —
        a shared platform, an estate-wide tag — can carry thousands of edges.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of the asset's relations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CursorPage"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Relation"
        "400":
          description: An unusable `limit` or `cursor`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/asset-types/{typeId}/assets/{assetId}/relations/{relationshipType}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
      - $ref: "#/components/parameters/AssetId"
      - $ref: "#/components/parameters/RelationshipType"
    put:
      tags: [Relations]
      operationId: setAssetRelations
      summary: Set an asset's targets for a relationship type
      description: >
        States EXACTLY these targets: authored relations of this type from this
        asset that you do not list are removed. Projected relations are not
        touched. Use POST to add without removing.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RelationTargets"
      responses:
        "200":
          description: How many rows were written.
          content:
            application/json:
              schema:
                type: object
                properties:
                  written:
                    type: integer
        "400":
          description: >
            A malformed `targets` list, a target that does not resolve in this
            workspace, or a relationship type derived from an asset field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Relations]
      operationId: addAssetRelations
      summary: Add targets for a relationship type
      description: >
        STRICTLY ADDITIVE — adds a relation to each target not already
        connected, and removes nothing. Idempotent: re-adding an existing target
        is a no-op.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RelationTargets"
      responses:
        "200":
          description: How many rows were written.
          content:
            application/json:
              schema:
                type: object
                properties:
                  written:
                    type: integer
        "400":
          description: As for PUT.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/asset-types/{typeId}/assets/{assetId}/relations/{relationshipType}/{targetType}/{targetId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
      - $ref: "#/components/parameters/AssetId"
      - $ref: "#/components/parameters/RelationshipType"
      - $ref: "#/components/parameters/TargetType"
      - $ref: "#/components/parameters/TargetId"
    patch:
      tags: [Relations]
      operationId: setRelationOrdinal
      summary: Set or clear one relation's ordinal
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ordinal]
              properties:
                ordinal:
                  type: integer
                  nullable: true
                  description: The position, or null to clear it.
      responses:
        "200":
          description: The stored ordinal.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ordinal:
                    type: integer
                    nullable: true
        "400":
          description: A missing or non-integer `ordinal`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The relation is projected from an asset field, not authored, so it cannot be edited.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Relations]
      operationId: deleteRelation
      summary: Delete one relation
      description: >
        Removes exactly this relation, leaving every sibling — and their
        ordinals — untouched.
      responses:
        "204":
          description: Deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The relation is projected from an asset field, not authored, so it cannot be deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /workspaces/{workspaceId}/relationship-types:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Relationship Types]
      operationId: listRelationshipTypes
      summary: List relationship types
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of relationship types.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CursorPage"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/RelationshipType"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/relationship-types/{relationshipTypeId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - name: relationshipTypeId
        in: path
        required: true
        description: The relationship type's slug.
        schema:
          type: string
    get:
      tags: [Relationship Types]
      operationId: getRelationshipType
      summary: Get a relationship type
      responses:
        "200":
          description: The relationship type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RelationshipType"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/asset-types/{typeId}/assets/bulk:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
    post:
      tags: [Assets]
      operationId: createAssetsBulk
      summary: Create many assets, with per-item results
      description: >
        For syncing an inventory. Each item is reported separately by its index,
        so a batch with two bad rows lands the rest and names the two.


        Capacity is stated for the WHOLE batch before any row lands: a batch that
        does not fit adds nothing rather than filling the remaining headroom and
        stopping partway.


        Status is `201` when every item succeeded, `400` when every item failed,
        and `207` only when the outcomes are genuinely mixed — so a caller
        branching on the status alone is never misled about the simple cases.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              maxItems: 1000
              items:
                $ref: "#/components/schemas/AssetCreate"
      responses:
        "201":
          description: Every asset was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkCreateResults"
        "207":
          description: Some assets were created and some were rejected.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkCreateResults"
        "400":
          description: A malformed body, or every item was rejected.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkCreateResults"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The batch would take the organization past its asset cap. Nothing was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /workspaces/{workspaceId}/asset-types/{typeId}/assets/bulk-op:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/TypeId"
    post:
      tags: [Assets]
      operationId: applyAssetsBulkOp
      summary: Apply one operation to many assets
      description: >
        Deletes, sets a status, or sets ONE field across a selection, and returns
        how many were affected.


        IDS THAT NO LONGER EXIST ARE DROPPED rather than failing the batch, and
        the returned count is what actually changed — not what was asked for. A
        selection is a snapshot, and a row can go between listing and acting.


        Setting a field MERGES: other field values on each asset are untouched.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BulkOp"
      responses:
        "200":
          description: How many assets were affected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  affected:
                    type: integer
        "400":
          description: A malformed selection, an unknown `op.kind`, a missing payload for the kind, or a field this type does not declare.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/views:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
    get:
      tags: [Views]
      operationId: listViews
      summary: List a workspace's views
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of views.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CursorPage"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/View"
        "400":
          description: An unusable `limit` or `cursor`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Views]
      operationId: createView
      summary: Create a view
      description: >
        `id` is NOT accepted. Unlike every other v3 resource, a view's slug is
        never freed — archiving does not release it — so the slug is derived
        from `name` and auto-suffixed (`-2`, `-3`, …) until free. Read the
        assigned `id` from the response rather than predicting it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ViewCreate"
      responses:
        "201":
          description: The created view.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/View"
        "400":
          description: A missing or empty `name`, or a missing `viewType`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/views/{viewId}:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/ViewId"
    get:
      tags: [Views]
      operationId: getView
      summary: Get a view
      responses:
        "200":
          description: The view.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/View"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Views]
      operationId: updateView
      summary: Update a view
      description: >
        `config` MERGES over what is stored — it does not replace it, which is
        the opposite of an asset's `fields`. `id` and `viewType` are not
        patchable: the slug is referenced from publications and studio URLs, and
        a viewType change would leave a map without its build seed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ViewUpdate"
      responses:
        "200":
          description: The updated view.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/View"
        "400":
          description: An empty body or an empty `name`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Views]
      operationId: archiveView
      summary: Archive a view
      description: >
        CASCADES: every live publication targeting this view is archived with
        it, and each of their slugs is freed for reuse — a publication over an
        archived view would otherwise stay live at its public URL.


        The view's OWN slug is not freed.
      responses:
        "204":
          description: Archived.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{workspaceId}/views/{viewId}/styles:
    parameters:
      - $ref: "#/components/parameters/WorkspaceId"
      - $ref: "#/components/parameters/ViewId"
    get:
      tags: [Views]
      operationId: listViewStyles
      summary: List a view's styles
      description: Read only — a style's layer payload moves with the map renderer.
      responses:
        "200":
          description: The view's live styles.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ViewStyle"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /publications:
    get:
      tags: [Publications]
      operationId: listPublications
      summary: List the organization's publications
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of publications.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CursorPage"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Publication"
        "400":
          description: An unusable `limit` or `cursor`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Publications]
      operationId: createPublication
      summary: Publish a view
      description: >
        Publishing requires an author-level key and a view in the organization's
        DEFAULT workspace — sandbox workspaces never reach the shopfront.


        The publication pins the view's active style at publish time, so
        changing a style in the studio afterwards never moves a live page.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublicationCreate"
      responses:
        "201":
          description: The created publication.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Publication"
        "400":
          description: A missing `id` or `name`, an unresolvable view target, or an unknown `presentationMode`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: >
            The key is not permitted to publish, or the target view is not in
            the default workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: That publication id is already in use in this organization.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /publications/{pubName}:
    parameters:
      - $ref: "#/components/parameters/PubName"
    get:
      tags: [Publications]
      operationId: getPublication
      summary: Get a publication
      responses:
        "200":
          description: The publication.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Publication"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Publications]
      operationId: updatePublication
      summary: Update a publication
      description: >
        Changing `id` RENAMES the publication and retires the old slug into
        `redirects`, so links already shared keep resolving.


        Retargeting `view` re-runs the full publish check — a publication cannot
        be moved onto a sandbox view after the fact.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublicationUpdate"
      responses:
        "200":
          description: The updated publication.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Publication"
        "400":
          description: An empty body, an empty `id`, or an unresolvable view target.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The key is not permitted to publish, or the new target view is not in the default workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The requested new id is already in use in this organization.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      tags: [Publications]
      operationId: deletePublication
      summary: Unpublish
      description: Archives the publication and FREES its slug for reuse.
      responses:
        "204":
          description: Unpublished.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API authentication via `Authorization: Bearer <your-api-key>`. Obtain
        your API key from Config → API in the Aplas web app.
  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum number of resources to return in one page.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
    Cursor:
      name: cursor
      in: query
      required: false
      description: >
        Opaque cursor from a previous response's `nextCursor`. Omit to fetch the
        first page.
      schema:
        type: string
    WorkspaceId:
      name: workspaceId
      in: path
      required: true
      description: The workspace's slug.
      schema:
        type: string
    TypeId:
      name: typeId
      in: path
      required: true
      description: The asset type's slug.
      schema:
        type: string
    RelationshipType:
      name: relationshipType
      in: path
      required: true
      description: The relationship type's slug.
      schema:
        type: string
    TargetType:
      name: targetType
      in: path
      required: true
      description: The target asset's type slug.
      schema:
        type: string
    TargetId:
      name: targetId
      in: path
      required: true
      description: The target asset's slug.
      schema:
        type: string
    ViewId:
      name: viewId
      in: path
      required: true
      description: The view's slug.
      schema:
        type: string
    PubName:
      name: pubName
      in: path
      required: true
      description: The publication's public slug.
      schema:
        type: string
    AssetId:
      name: assetId
      in: path
      required: true
      description: >
        The asset's slug. Unique within its asset type, not across the
        workspace — which is why assets are addressed beneath their type.
      schema:
        type: string
  schemas:
    Error:
      type: object
      required:
        - statusCode
        - error
        - message
      properties:
        statusCode:
          type: integer
          description: The HTTP status code.
          example: 403
        error:
          type: string
          description: The HTTP status phrase.
          example: Forbidden
        message:
          type: string
          description: A human-readable explanation of what went wrong.
          example: The permissions of your API key only allow read-only/GET calls.
    CursorPage:
      type: object
      description: >
        The envelope every list endpoint returns. Concrete endpoints narrow
        `data` to their own resource type.
      required:
        - data
        - nextCursor
      properties:
        data:
          type: array
          description: The page of resources.
          items: {}
        nextCursor:
          type: string
          nullable: true
          description: >
            Pass as `cursor` to fetch the next page. `null` when this is the
            last page.
    Workspace:
      type: object
      required: [id, name, isArchived]
      properties:
        id:
          type: string
          description: The workspace's slug. Unique within your organization.
          example: main
        name:
          type: string
        description:
          type: string
          nullable: true
        isArchived:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    WorkspaceCreate:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
          description: >
            The slug to address this workspace by. Chosen once and not
            changeable afterwards.
        name:
          type: string
        description:
          type: string
    WorkspaceUpdate:
      type: object
      properties:
        name:
          type: string
        description:
          type: string
    FieldSpec:
      type: object
      required: [id, name, type]
      description: One field in an asset type's schema.
      properties:
        id:
          type: string
          description: >
            Unique within the asset type. This is the key the value is stored
            under in an asset's `fields` object.
        name:
          type: string
          description: The human label, shown in the studio and in exports.
        description:
          type: string
          description: Optional help text, shown under the input in the studio.
        type:
          type: string
          description: >
            What the field stores, and how its values are validated. Changing
            the type of a field that already holds values is destructive and is
            refused.
          enum:
            - string
            - text
            - markdown
            - number
            - integer
            - boolean
            - date
            - datetime
            - email
            - url
            - color
            - currency
            - choice
            - rating
            - image
            - file
            - relation
        options:
          type: array
          description: >
            The permitted values, for `choice` fields. Required for them — a
            choice with no options accepts nothing. Values are matched against
            each option's `value`, never its `label`.
          items:
            $ref: "#/components/schemas/ChoiceOption"
        subType:
          type: string
          description: >
            For `relation`, the asset type slug this field points at. For
            `choice`, unused — declare the permitted values in `options`.
        min:
          type: number
          description: >
            Lower bound. Minimum length for the string-like types, minimum value
            for the numeric ones.
        max:
          type: number
          description: Upper bound, with the same meaning as `min`.
    ChoiceOption:
      type: object
      required: [value, label]
      description: One permitted value of a `choice` field.
      properties:
        value:
          type: string
          description: >
            What is stored on the asset, and what an API caller must send.
        label:
          type: string
          description: What is displayed for that value in the studio.
    AssetType:
      type: object
      required: [id, nameSingular, namePlural, isBuiltIn, fieldSpecs]
      properties:
        id:
          type: string
          description: The type's slug. Unique within the workspace.
          example: application
        nameSingular:
          type: string
        namePlural:
          type: string
        description:
          type: string
          nullable: true
        icon:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        isBuiltIn:
          type: boolean
          description: >
            Seeded from the standard template. Built-in types are editable but
            cannot be deleted. Always false for types created through this API.
        fieldSpecs:
          type: array
          items:
            $ref: "#/components/schemas/FieldSpec"
        viewRules:
          type: object
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    AssetTypeCreate:
      type: object
      required: [id, nameSingular, namePlural]
      properties:
        id:
          type: string
          description: >
            Lowercase, starting with a letter, up to 63 characters. Some slugs
            are reserved by the studio's own routes.
        nameSingular:
          type: string
        namePlural:
          type: string
        description:
          type: string
        icon:
          type: string
        color:
          type: string
        fieldSpecs:
          type: array
          items:
            $ref: "#/components/schemas/FieldSpec"
        viewRules:
          type: object
    AssetTypeUpdate:
      type: object
      properties:
        nameSingular:
          type: string
        namePlural:
          type: string
        description:
          type: string
        icon:
          type: string
        color:
          type: string
        fieldSpecs:
          type: array
          items:
            $ref: "#/components/schemas/FieldSpec"
        viewRules:
          type: object
        confirmDestructive:
          type: boolean
          description: >
            Required when a fieldSpec edit would strand existing data — removing
            a field, or changing its type, where assets already carry values.
    Asset:
      type: object
      required: [id, name, fields]
      properties:
        id:
          type: string
          description: The asset's slug, unique within its type.
        name:
          type: string
        shortName:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        status:
          type: integer
          nullable: true
        fields:
          type: object
          additionalProperties: true
          description: >
            The asset's field values, keyed by field id. Returned in full,
            including fields marked hidden — your key reads your organization's
            own data, and the hidden-field rule exists for published views and
            anonymous viewers. Hidden fields are still not FILTERABLE.
        sample:
          type: boolean
          description: Whether this row was generated as sample data.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    AssetCreate:
      type: object
      required: [name]
      properties:
        id:
          type: string
          description: >
            Optional. Lowercase, starting with a letter, up to 63 characters of
            letters, digits or hyphens. Derived from `name` when omitted.
        name:
          type: string
        shortName:
          type: string
        description:
          type: string
        status:
          type: integer
        fields:
          type: object
          additionalProperties: true
    AssetUpdate:
      type: object
      description: Every property optional; supply at least one.
      properties:
        name:
          type: string
        shortName:
          type: string
        description:
          type: string
        status:
          type: integer
        fields:
          type: object
          additionalProperties: true
          description: Replaces the stored field bag wholesale.
        patchFields:
          type: object
          additionalProperties: true
          description: >
            Merges over the stored field bag, leaving unnamed keys untouched.
            Mutually exclusive with `fields`.
    AssetRef:
      type: object
      required: [type, id]
      description: >
        An asset named by its type's slug plus its own slug. A slug alone is not
        enough — it is unique only within its type.
      properties:
        type:
          type: string
        id:
          type: string
    RelationTargets:
      type: object
      required: [targets]
      properties:
        targets:
          type: array
          maxItems: 1000
          description: >
            At most 1000 per call — the writes are serial inside one
            transaction, the same bound the asset bulk endpoints use.
          items:
            $ref: "#/components/schemas/AssetRef"
    Relation:
      type: object
      required: [relationshipType, direction, projected]
      properties:
        relationshipType:
          type: string
          nullable: true
        direction:
          type: string
          enum: [outgoing, incoming]
        asset:
          type: object
          nullable: true
          description: The asset at the other end, named by type and slug, plus its display name.
          properties:
            type:
              type: string
              nullable: true
            id:
              type: string
            name:
              type: string
        ordinal:
          type: integer
          nullable: true
        projected:
          type: boolean
          description: >
            True when this relation is derived from an asset's own field rather
            than authored directly. Projected relations cannot be edited or
            deleted through this API — change the projecting asset instead.
        fields:
          type: object
          additionalProperties: true
    RelationshipType:
      type: object
      required: [id, name, isBuiltIn]
      properties:
        id:
          type: string
          description: The relationship type's slug.
        name:
          type: string
          description: How the edge reads from source to target — "depends on".
        reverseName:
          type: string
          nullable: true
          description: How it reads the other way — "depended on by".
        description:
          type: string
          nullable: true
        isBuiltIn:
          type: boolean
    BulkCreateResults:
      type: object
      required: [results]
      properties:
        results:
          type: array
          description: One entry per submitted item, in the order you sent them.
          items:
            type: object
            required: [index, status]
            properties:
              index:
                type: integer
                description: The item's position in your request array.
              status:
                type: integer
                description: 201 when created, 400 when rejected.
              asset:
                $ref: "#/components/schemas/Asset"
              error:
                type: object
                properties:
                  message:
                    type: string
    BulkOp:
      type: object
      required: [assetIds, op]
      properties:
        assetIds:
          type: array
          maxItems: 1000
          items:
            type: string
          description: Asset slugs. Duplicates are collapsed; unknown ids are skipped.
        op:
          type: object
          required: [kind]
          properties:
            kind:
              type: string
              enum: [delete, status, field]
            status:
              type: integer
              description: Required when kind is `status`. `0` is a valid status.
            fieldId:
              type: string
              description: >
                Required when kind is `field`. Must be a field this asset type
                declares and has not hidden.
            value:
              description: The value to set. Null clears the field.
    View:
      type: object
      required: [id, name, viewType, config]
      properties:
        id:
          type: string
          description: The view's slug, unique within its workspace and never freed.
        name:
          type: string
        description:
          type: string
          nullable: true
        viewType:
          type: string
          description: e.g. `table`, `matrix`, `graph`, `map`.
        config:
          type: object
          additionalProperties: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    ViewCreate:
      type: object
      required: [name, viewType]
      properties:
        name:
          type: string
          description: The slug is derived from this and returned as `id`.
        viewType:
          type: string
          enum: [map, table, matrix, graph, search]
        config:
          type: object
          additionalProperties: true
    ViewUpdate:
      type: object
      description: Supply `name`, `config`, or both. `config` merges.
      properties:
        name:
          type: string
        config:
          type: object
          additionalProperties: true
    ViewStyle:
      type: object
      required: [id, name, isActive]
      properties:
        id:
          type: string
        name:
          type: string
        isActive:
          type: boolean
          description: The style a new publication of this view would pin.
        layers:
          type: array
          items:
            type: object
            additionalProperties: true
    Publication:
      type: object
      required: [id, name, enabled, redirects]
      properties:
        id:
          type: string
          description: The public slug this publication is served at.
        view:
          type: object
          nullable: true
          description: >
            The view this publication renders, named by slugs. Inlined rather
            than `$ref: ViewRef` with a sibling `nullable`: in OpenAPI 3.0.1
            `nullable` alongside `allOf` is not reliably applied, and this field
            is genuinely null for a publication with no view target.
          properties:
            workspace:
              type: string
            id:
              type: string
        name:
          type: string
        description:
          type: string
          nullable: true
        enabled:
          type: boolean
        accessType:
          type: integer
          nullable: true
        presentationMode:
          type: string
          nullable: true
          enum: [standard, style-first]
        redirects:
          type: array
          description: Retired slugs that still resolve to this publication.
          items:
            type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    ViewRef:
      type: object
      required: [workspace, id]
      description: A view named by its workspace's slug and its own.
      properties:
        workspace:
          type: string
        id:
          type: string
    PublicationCreate:
      type: object
      required: [id, name, view]
      properties:
        id:
          type: string
          description: The public slug to serve this publication at.
        name:
          type: string
        description:
          type: string
        view:
          $ref: "#/components/schemas/ViewRef"
        enabled:
          type: boolean
        accessType:
          type: integer
        presentationMode:
          type: string
          enum: [standard, style-first]
        viewStyleId:
          type: string
          description: An explicit style pin. Must belong to the target view; omit to pin the active one.
    PublicationUpdate:
      type: object
      description: Every property optional; supply at least one.
      properties:
        id:
          type: string
          description: A new public slug. The old one is retired into `redirects`.
        name:
          type: string
        description:
          type: string
        view:
          $ref: "#/components/schemas/ViewRef"
        enabled:
          type: boolean
        accessType:
          type: integer
        presentationMode:
          type: string
          enum: [standard, style-first]
        viewStyleId:
          type: string
          nullable: true
          description: Null re-pins to the target view's currently active style.
  responses:
    Unauthorized:
      description: >
        No credential was supplied, or the credential was not recognised.
      headers:
        WWW-Authenticate:
          description: >
            RFC 6750 challenge. A bare `Bearer realm="aplas"` means no credential
            was sent; `error="invalid_token"` means one was sent and did not
            validate.
          schema:
            type: string
          example: Bearer realm="aplas", error="invalid_token"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: >
        The key is valid but is not permitted this operation — for example a
        read-only key attempting a write, or a key whose organization is on the
        other API version.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: >
        No such resource in this organization. Also returned when a resource
        exists but belongs to another organization, so that the API does not
        reveal which identifiers are in use elsewhere.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
