# API v1 cannot set `last_used_filter`, so every view created through the API opens unfiltered in the UI

**Product:** Plane Cloud (`app.plane.so` / `api.plane.so`, API `v1`)

**Observed:** 2026-08-04

**Auth:** Personal Access Token (`X-API-Key`)

**Severity (our view):** high — the view looks correct and shows the wrong thing

-–

## Summary

A project view stores three filter representations plus a selector of which one

applies:

| Field | Consumed by | Writable via API v1 |

|—|—|—|

| `filters` | server-side, translated into `query` | yes |

| `pql_filters` | the UI, when the PQL mode is active | yes |

| `rich_filters` | the UI in `Basic` mode (the default) | **not even returned by GET** |

| `last_used_filter` | selects which of the three the UI applies | **no** |

A view created through v1 gets `last_used_filter: “rich_filters”` with

`rich_filters: {}`. Result: **the screen shows every work item in the project**,

however correct `filters` and `pql_filters` are. There is no way, through the

public API, to make the view open filtered.

We provisioned eight views (portfolio radar, current sprint, external

dependencies, defect queues). All eight displayed all 36 work items of the

project. The filters were stored correctly the whole time — clicking `PQL` in the

filter bar makes each view work immediately.

## Why it is worse than a plain missing feature

The view does not look broken. It has a name, a description, a saved filter, and

it renders a list. A person opening “Current sprint” sees a full board and has no

reason to suspect it is unfiltered. We only found out because a teammate noticed

the item count was identical across four differently-filtered views.

Validating through the API does not catch it either: `GET` on the view returns

`filters` **and** a server-translated `query`

(`{“type__in”: […]}`), which proves the backend understood the filter — not

that the UI applies it.

## Steps to reproduce

1. `POST /api/v1/workspaces/{slug}/projects/{id}/views/` with a valid filter, e.g.

```json

{

 "name": "API view",

 "access": 1,

 "filters": {"issue_type": \["<type_uuid>"\]},

 "pql_filters": {

   "json": {"type":"doc","content":\[{"type":"paragraph","content":\[

     {"text":"type = ","type":"text"},

     {"type":"pqlValue","attrs":{"option":{"id":"<type_uuid>","label":"Bug","value":"<type_uuid>"}}}

   \]}\]},

   "stripped": "type = \\"<type_uuid>\\" "

 },

 "display_filters": {"layout": "spreadsheet", "sub_issue": true}

}

```

2. Open the view in the web UI.

**Expected:** only work items of that type.

**Actual:** every work item in the project. The filter bar opens in `Basic`, and

`rich_filters` is empty. Clicking `PQL` applies the stored filter correctly.

## Evidence

### E1 — `last_used_filter` is dropped while other fields in the same request persist

This is the core of the report. We sent a v1 `PATCH` with the **exact payload the

UI sends** (captured from the browser’s network tab when clicking *Update view*):

```json

{

“display_filters”: {“calendar”:{“show_weekends”:false,“layout”:“month”},“layout”:“spreadsheet”,

                  "order_by":"-created_at","group_by":"priority","sub_group_by":null,

                  "sub_issue":true,"show_empty_groups":true},

“display_properties”: {“assignee”:true,“issue_type”:true,“dependencies”:true,“releases”:true, },

“last_used_filter”: “pql_filters”,

“pql_filters”: { }

}

```

Response: `200`. Reading the view back (through the internal API, since v1 does

not expose the field):

```json

“display_properties”: { “issue_type”: true, “dependencies”: true, }, // persisted

“display_filters”: { “calendar”: {}, “sub_group_by”: null, }, // persisted

“last_used_filter”: “rich_filters”, // DROPPED

“total_work_items”: 36

```

Same request, same serializer: `display_properties` and `display_filters` were

stored, `last_used_filter` was silently discarded.

### E2 — It also fails isolated, with another value, and on create

| Attempt | Result |

|—|—|

| `PATCH {“last_used_filter”: “pql_filters”}` | `200`, not persisted |

| `PATCH {“last_used_filter”: “pql”}` | `200`, not persisted |

| `POST` at creation, field in the body | `201`, not persisted, view opens unfiltered |

### E2b — Controlled A/B: identical payload, only the endpoint differs

We took the **exact `POST` payload the web app sends** (captured from the browser)

and replayed it through v1, creating two views that differ in one single field:

| View | Created via | `filters` in body | `last_used_filter` in body | Filters on screen? |

|—|—|—|—|—|

| A | API v1 | **omitted** (exactly like the UI) | `“pql_filters”` | **no** |

| B | API v1 | present | `“pql_filters”` | **no** |

| C | **web UI** | omitted | `“pql_filters”` | **yes** |

A, B and C carry byte-identical `pql_filters` (same AST, same `stripped`), the same

`display_filters`, the same `display_properties`, `logo_props: {}` and `access: 1`.

This rules out every explanation except the endpoint:

- not the payload shape — A replays the UI’s body verbatim;

- not the presence of the legacy `filters` — A omits it and still fails;

- not the PQL content — C proves the same expression works.

The only difference between A and C is that C went to

`/api/workspaces/{slug}/…` and A went to `/api/v1/workspaces/{slug}/…`.

### E3 — The UI succeeds through the internal API, which rejects a PAT

The web app sends the same field to `/api/workspaces/{slug}/projects/{id}/views/{id}/`

(no `/v1`) and it persists. That route answers a Personal Access Token with:

```

401 {“detail”:“Authentication credentials were not provided.”}

```

So the capability exists server-side; it is only unreachable from a machine

credential.

### E4 — `rich_filters` is invisible to v1

`GET` on the view through v1 does not include `rich_filters` (nor

`last_used_filter`). We saved a filter in `Basic` mode through the UI: the view’s

`updated_at` changed, and **nothing** in the v1 payload changed — `filters`,

`query` and `pql_filters` were all byte-identical before and after. So the field

the default mode reads cannot be inspected or written through the public API.

## What we are asking for

Any of these, in order of preference:

1. **Make `last_used_filter` writable via v1** (`POST` and `PATCH`). One field,

and provisioning works end to end.

2. **Make `rich_filters` readable and writable via v1** — then automation can

populate the mode the UI already opens in, without switching modes.

3. **Have the UI fall back to `pql_filters` when `rich_filters` is empty.** This

would fix every already-created view without any API change, and it is hard to

see a case where "the Basic filter is empty, so ignore the saved PQL filter and

show everything" is the desired behaviour.

4. At minimum: **reject unknown/non-writable fields with `400`** instead of

returning `200`. See the note below — this is the pattern that made every

discovery in this integration slower than it needed to be.

## Recurring pattern: silent acceptance of non-writable fields

Beyond this issue, in the same integration:

- `PATCH` on a project with a non-existent field → `200`, no change.

- `POST /work-item-types/` with `level: 1, is_epic: true` → `201`, stored as

`level=0, is_epic=false`.

- `logo_props` on a work item type → accepted on `POST` and `PATCH`, always

ignored; the server assigns a random icon.

- `archived_at` in a page `PUT` → accepted, reads back `null`.

In each case a `400` would have replaced hours of probing with one clear error.

## Our current workaround

Provisioning writes `filters` + `pql_filters` (both correct) and emits a warning

telling operators the views need one click on `PQL` per view. We chose to warn

rather than stay silent, because a view that shows everything while looking

filtered is worse than no view at all.

## Environment notes

- Views are otherwise well-behaved through v1: `PATCH` and `DELETE` work on the

detail, renaming preserves the id and saved state.

- Related quirk worth documenting on your side: view names are unique **per

workspace**, not per project, so provisioning the same view set into a second

project fails with `400 VIEW_NAME_ALREADY_EXISTS` on every view.

Thanks for the detailed writeup.

We’ll be enabling last_used_filter to be writable through API v1, both on create and update and it should be live by next week. We’ll also be picking this up to make rich_filters readable and writable through API v1.