# Project archive API

> Archive and unarchive a project, and understand the default-active list contract, over the integrator API.

Every project carries an `archivedAt` timestamp (epoch ms, or `null` for an active project) — a
**reversible** lifecycle flag, not a soft-delete. `{projectId}` is the project **code** (for example
`ACME`) everywhere below.

Archive makes a project's **content read-only** while its **administration stays reachable**:

- Every existing route stays fully **readable**: an archived project's detail page, suites, cases,
  runs, plans, milestones, defects, attachments, comments and exports all keep working exactly as
  they do for an active project. `GET /api/v1/projects/{projectId}` still returns `200`, with a
  non-null `archivedAt`.
- Every **write to content** is refused `409 { code: "project_archived" }` — see
  [Content writes are refused while archived](#content-writes-are-refused-while-archived) below.
- It does **not** release a project seat. An organization at its `projectLimit` that archives a project is
  still refused a new one with `409 { code: "project_limit_exceeded" }` — deleting remains the only way to
  free a seat.

## Breaking change: the list defaults to active only

**`GET /api/v1/projects` now hides archived projects by default.** This is a deliberate change to a
shipped public contract: an integrator polling this endpoint starts receiving fewer rows once anything is
archived in that organization. It is observationally inert on the day this ships, because no project can
be archived until this capability lands — but it takes effect immediately once you (or anyone in your
organization) archives a project.

Pass the optional `status` query parameter to change what the list returns:

| `status` value                       | Result                                         |
| ------------------------------------ | ---------------------------------------------- |
| absent, empty, or all-unknown tokens | **Active only** — the new default              |
| `active`                             | Active only (same as the default, spelled out) |
| `archived`                           | Archived only                                  |
| `active,archived` (either order)     | Both — the full pre-existing behavior          |

`status` is a comma-separated token set, matching the existing `runs`/`defects`/`milestones`/`cases`
filters on this endpoint. It composes with pagination, `q`, `memberUlid`, and every other filter, and the
returned `total` always agrees with the applied filter (never a phantom count from an unfiltered query).

```bash
# Only what changed since the flip: archived projects
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects?status=archived"

# Everything, exactly like before this change shipped
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects?status=active,archived"
```

Every other consumer of the projects list inherits this same default with **zero** extra work on your
part: the web directory, the project switcher, and any other internal reader all resolve to active-only
unless they too pass `status` explicitly.

## Archive a project

`POST /api/v1/projects/{projectId}/archive`

No request body. Returns `200` with the updated project (see
[Project access](https://docs.probara.net/en/developers/api/project-access/) for the sibling resource; this is the plain project
object with its `archivedAt` field).

```json
{
  "ulid": "01J...PROJECT",
  "name": "Acme",
  "id": "ACME",
  "description": null,
  "avatarKey": null,
  "avatarVersion": 0,
  "createdAt": 1700000000000,
  "updatedAt": 1700000005000,
  "archivedAt": 1700000005000
}
```

**Idempotent**: archiving an already-archived project responds `200` without changing the stored
timestamp — a double-click or a retried request behaves identically to a single call, and this endpoint
never answers `409`.

### Authorization

Same gate as `PATCH`/`DELETE` on a project: only an organization **owner** or **admin** may archive.
A `member` or `viewer` is refused `403 forbidden`, with `archivedAt` left unchanged. An unknown
`{projectId}`, or one belonging to another organization, answers `404 not_found`.

## Unarchive a project

`POST /api/v1/projects/{projectId}/unarchive`

No request body. Returns `200` with the updated project, `archivedAt: null`. Same idempotency and
authorization rules as archiving, mirrored: unarchiving a project that is not archived succeeds and
leaves `archivedAt` as `null`.

### Example

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects/ACME/archive"
```

## Content writes are refused while archived

An archived project's content is frozen. Any write into it — creating a suite, updating a test
case, starting a run, saving a run result, linking a defect, mutating a test plan, writing a
custom-field value, creating a comment, uploading an attachment or an avatar, and every other
project-scoped content mutation — is refused:

```json
{
  "error": {
    "code": "project_archived",
    "message": "project is archived"
  }
}
```

**Status `409`.** This applies whether the write targets the project directly
(`/api/v1/projects/{projectId}/...`) or an entity reached by its own ULID
(`/api/v1/test-cases/{ulid}`, `/api/v1/runs/{ulid}`, and so on) — the refusal is enforced where the
data lives, not where the URL points, so there is no route that bypasses it. An attachment upload
refused this way never reaches storage: no partial or orphaned object is left behind. This applies
to every credential kind, including an organization API token.

Refusal ordering is fixed: a project that does not exist (or belongs to another organization)
answers `404` before archived state is ever consulted; a caller without access to a private
project answers `403`; only a caller who would otherwise have been admitted reaches the `409`. The
archived state never leaks the existence of a project the caller cannot see.

This is a breaking change to a shipped public contract, and unlike the list-default change above
it is **not observationally inert** — any project archived before this change now refuses writes
the moment it ships.

### Exempt administrative operations

These operations manage the project itself rather than its content, and keep succeeding while the
project is archived:

| Operation                                       | Route                                                                       |
| ----------------------------------------------- | --------------------------------------------------------------------------- |
| Unarchive                                       | `POST /api/v1/projects/{projectId}/unarchive`                               |
| Delete                                          | `DELETE /api/v1/projects/{projectId}`                                       |
| Archive (idempotent re-archive)                 | `POST /api/v1/projects/{projectId}/archive`                                 |
| Update (rename/re-describe)                     | `PATCH /api/v1/projects/{projectId}`                                        |
| Access management (mode, membership, ownership) | `PATCH /api/v1/projects/{projectId}/access` and its member/group sub-routes |

The project's own **avatar** is content, not administration, and is **not** exempt:
`POST`/`DELETE /api/v1/projects/{projectId}/avatar` are refused `409` like any other write.

### Idempotent retries

A write refused `409 project_archived` under an `Idempotency-Key` is recorded like any other
response below `500`, so an identical retry within the key's TTL replays the same recorded `409`
— even after the project has since been unarchived. Send a fresh, unused key to get the
post-unarchive outcome. See [Idempotency](https://docs.probara.net/en/developers/api/idempotency/) for the general contract.

## Known limitation

Hard delete (`DELETE /api/v1/projects/{projectId}`) is unrelated to archive and remains the only
irreversible removal path. Deleting does not reclaim object storage — the project's picture and every
descendant file survive the cascade. This is pre-existing, out of scope here, and tracked as
its own future change.
