# Tags API

> Create, list, rename, and delete the organization-wide tag catalog over the integrator API.

Tags are an organization-wide vocabulary shared by test cases, defects, and test runs. There is one catalog per organization, mounted under `/api/v1/orgs/{orgUlid}/tags`. Each tag has a server-assigned ULID, a name unique within the organization, an author, and three usage counts.

Entities never reference a tag by ULID. A test case, a defect, and a test run all carry `tags: string[]` — the names — and the server resolves or creates the catalog row behind them. See [Attaching tags to entities](#attaching-tags-to-entities).

## Create

`POST /api/v1/orgs/{orgUlid}/tags`

Requires the tag write permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`.

### Request body

| Field  | Required | Notes                                                                                            |
| ------ | -------- | ------------------------------------------------------------------------------------------------ |
| `name` | yes      | Trimmed, 1–80 characters. Must be unique within the organization (see [Name rules](#name-rules)) |

The body is strict: any other key returns `422 validation_failed`.

### Response

`201` with the created tag. Its three counts are `0` — a tag one statement old is attached to nothing. See [Response fields](#response-fields).

A duplicate name returns `409 conflict`.

### Example

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"smoke"}' \
  "https://app.probara.net/api/v1/orgs/01JXXXXXXXXXXXXXXXXXXXXXXXXX/tags"
```

### Name rules

- The name is trimmed before it is stored and before it is compared, so `"  smoke  "` and `"smoke"` are the same tag.
- Length is measured after trimming: 1 to 80 characters. A whitespace-only name returns `422 validation_failed`.
- Uniqueness is per organization and **case-sensitive**. `Smoke` and `smoke` are two different tags and may both exist; only a trim-exact repeat returns `409 conflict`. The same name is accepted in a different organization.

## List

`GET /api/v1/orgs/{orgUlid}/tags`

No permission gate: every member of the organization may read the catalog. Page-based pagination.

The order is **name ascending** and is fixed — there are no sort parameters.

### Query parameters

| Parameter    | Notes                                                                                                                                                                                                                           |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `page`       | Page number (1-based, default `1`)                                                                                                                                                                                              |
| `pageSize`   | Items per page (default `50`, max `200`)                                                                                                                                                                                        |
| `q`          | Case-insensitive substring search on the tag name, max 80 characters                                                                                                                                                            |
| `authorUlid` | Comma-separated user ULIDs; matches tags created by any of them. A ULID that resolves to no member contributes nothing, and a filter resolving to no members at all matches nothing rather than degrading to an unfiltered list |

The query is strict: a misspelled parameter (`authorUlids`, `pageSizes`) returns `422 validation_failed` rather than being silently dropped.

### Response

`200` with `{ items, page, pageSize, total }`. Each item follows the [response shape](#response-fields).

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/orgs/01JXXXXXXXXXXXXXXXXXXXXXXXXX/tags?q=smoke&pageSize=100"
```

## Update

`PATCH /api/v1/orgs/{orgUlid}/tags/{tagUlid}`

Requires the tag write permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`.

### Request body

| Field  | Required | Notes                                                           |
| ------ | -------- | --------------------------------------------------------------- |
| `name` | yes      | The new name. Same trim, length, and uniqueness rules as create |

`name` is the only field, so an empty body returns `422 validation_failed`.

### Response

`200` with the renamed tag, the same shape `POST` returns — with the tag's **real** counts, not zeros. A rename reaches every test case, defect, and test run carrying the tag without detaching or otherwise modifying any of them.

A name another tag already holds returns `409 conflict`. A `{tagUlid}` outside the organization returns `404 not_found`; a malformed one returns `422 validation_failed`.

### Example

```bash
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"smoke-suite"}' \
  "https://app.probara.net/api/v1/orgs/01JXXXXXXXXXXXXXXXXXXXXXXXXX/tags/01JTAGXXXXXXXXXXXXXXXXXXXXX"
```

## Delete

`DELETE /api/v1/orgs/{orgUlid}/tags/{tagUlid}`

Requires the tag delete permission (held by `owner`/`admin`); a `member` and a `viewer` both receive `403 forbidden`. The split is deliberate: deleting a tag detaches it from every test case, defect, and test run that carries it, and that reaches further than growing the vocabulary does.

Returns `204`. The entities themselves are untouched — only the tag row and its attachments are removed. A `{tagUlid}` outside the organization returns `404 not_found`.

### Example

```bash
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/orgs/01JXXXXXXXXXXXXXXXXXXXXXXXXX/tags/01JTAGXXXXXXXXXXXXXXXXXXXXX"
```

## Bulk delete

`POST /api/v1/orgs/{orgUlid}/tags/bulk-delete`

Requires the tag delete permission (held by `owner`/`admin`); a `member` and a `viewer` both receive `403 forbidden`.

### Request body

| Field   | Required | Notes                                                                           |
| ------- | -------- | ------------------------------------------------------------------------------- |
| `ulids` | yes      | 1 to 100 tag ULIDs. An empty or oversized array returns `422 validation_failed` |

### Response

`200` with `{ deleted: number }` — how many rows were actually removed. A batch is forgiving by design: a ULID this organization does not own, or one a concurrent delete already took, is skipped rather than failing the request. `deleted` is therefore not always the batch size, and it is the only signal that your selection contained something you do not own.

Each deletion detaches the tag exactly as the single `DELETE` does.

### Example

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ulids":["01JTAGAXXXXXXXXXXXXXXXXXXXX","01JTAGBXXXXXXXXXXXXXXXXXXXX"]}' \
  "https://app.probara.net/api/v1/orgs/01JXXXXXXXXXXXXXXXXXXXXXXXXX/tags/bulk-delete"
```

## Response fields

`POST`, `PATCH`, and each item of the list response return a tag object with the following fields. The object is strict — no other key ships.

| Field         | Type           | Notes                                                           |
| ------------- | -------------- | --------------------------------------------------------------- |
| `ulid`        | string (ULID)  | Server-assigned unique identifier                               |
| `name`        | string         | The tag name, exactly as entities carry it                      |
| `createdBy`   | object \| null | Author summary, or `null` (see [Author](#author))               |
| `caseCount`   | number         | Test cases carrying the tag (see [Usage counts](#usage-counts)) |
| `runCount`    | number         | Test runs carrying the tag                                      |
| `defectCount` | number         | Defects carrying the tag                                        |
| `createdAt`   | number         | Unix epoch milliseconds                                         |
| `updatedAt`   | number         | Unix epoch milliseconds                                         |

`createdBy`, when present, carries `ulid`, `displayName`, `avatarKey`, `avatarVersion`, and `positionTitle`.

### Usage counts

The three counts are not organization-wide totals. Two properties matter to an integrator:

- **They are scoped to the projects the caller may read.** A tag attached to a case in a project the caller has no access to is not counted for that caller. Two credentials can legitimately read different counts for the same tag, and neither is stale.
- **They include archived (trashed) test cases.** This is deliberate: a tag whose only user is archived must not read `0` and invite a delete that would strip the tag the moment the case is restored.

A `0` therefore means "nothing visible to this caller carries the tag", not "the tag is unused".

### Author

`createdBy` is `null` when the tag has no resolvable author. Two distinct cases produce it:

- The tag was created by an **API token or an MCP agent**. Such a tag has no human author by design.
- The author's account was erased.

A tag whose author merely left the organization still carries their summary.

## Attaching tags to entities

Test cases, defects, and test runs carry `tags: string[]` — **names, not ULIDs** — on both the request and the response. Test runs gained the field alongside this catalog: at most 50 names per run, each 1 to 80 characters, on `POST` and `PATCH` and in the run response.

The server resolves each submitted name against the catalog and **creates the row when the name is new**, so posting a name the catalog has never seen mints the tag — no separate `POST /tags` required. A create body's array attaches; a `PATCH` array replaces the entity's whole tag set.

Two consequences of the name-based contract are worth coding against:

- A submitted `[]` (and a submitted `null`) clears the set, and the entity reads back with `tags: null`, not `tags: []`. Omitting the key leaves the existing set untouched.
- The returned array is always **alphabetical**, never submission order. Do not compare it positionally to what you sent.

Deleting a tag from the catalog removes it from every entity that carried it; the entities are otherwise untouched, and their `tags` arrays simply come back shorter.

## MCP tools

The same five operations are exposed to MCP clients as `list_tags`, `create_tag`, `update_tag`, `delete_tag`, and `bulk_delete_tags`. Each takes `org_id` (from `get_current_organization`) and enforces the same permissions as the HTTP route behind it. A tag an MCP agent creates has `createdBy: null`, as [Author](#author) describes. See [MCP tools](https://docs.probara.net/en/developers/mcp-tools/).
