diff --git a/specs/37066-content-drive-status-filter/contracts/drive-search-status.md b/specs/37066-content-drive-status-filter/contracts/drive-search-status.md new file mode 100644 index 000000000000..dcd9722604fc --- /dev/null +++ b/specs/37066-content-drive-status-filter/contracts/drive-search-status.md @@ -0,0 +1,194 @@ +# Contract: `status` on `POST /api/v1/drive/search` + +**Feature**: [../spec.md](../spec.md) | **Date**: 2026-08-24 + +One new optional field on an existing request body. No new endpoint, no breaking change: a request +that omits `status` behaves byte-identically to today (FR-002). + +Resource: `dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveResource.java` (`@Path("/v1/drive")`, `@Path("/search")`, `@POST`). + +--- + +## Request + +```jsonc +{ + "assetPath": "//demo.dotcms.com/marketing/", + "status": ["UNPUBLISHED", "LOCKED"] // NEW — optional, defaults to [] + // …every existing field unchanged +} +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `status` | `string[]` | No | `[]` | Content states to filter by. Accepted: `ARCHIVED`, `UNPUBLISHED`, `LOCKED`. Entries combine with **OR**. | + +### Semantics + +| Selection | Returns | +|---|---| +| `[]` (or omitted) | Today's behavior: archived excluded, everything else returned | +| `["ARCHIVED"]` | Only archived content | +| `["UNPUBLISHED"]` | Only content with no live version, archived excluded | +| `["LOCKED"]` | Only content with a lock held, by anyone, archived excluded | +| `["UNPUBLISHED","LOCKED"]` | Content that is unpublished **or** locked, archived excluded | +| `["ARCHIVED","UNPUBLISHED"]` | Everything with no live version — archived **or** unpublished | +| `["ARCHIVED","LOCKED"]` | Archived content **or** content with a lock held | +| all three | Anything not cleanly published — archived, unpublished **or** locked | + +**OR, not AND.** Selecting more statuses returns *more* content, exactly like `contentTypes`, +`baseTypes` and `language`. Adding a status can never shrink the result set. + +**OR applies *within* `status` only. Separate filters still AND with each other**, exactly as they +do today: `{"workflow": [...], "status": ["UNPUBLISHED","LOCKED"]}` means *governed by that workflow* +**and** *(unpublished **or** locked)*. Each filter appends its own `and ( … )` clause server-side +(`BrowserAPIImpl:2338` for workflow), so adding `status` never loosens another filter. + +**The archived exclusion is a baseline, not a fourth value, and it sits outside the OR group.** +Every drive request already excludes archived content; the selected statuses are OR-ed together and +that group is AND-ed against the baseline, which only `ARCHIVED` lifts. That is why +`["UNPUBLISHED"]` means "unpublished and not archived" without contradicting the OR rule. Folding +the baseline into the group instead would make `["UNPUBLISHED","LOCKED"]` match nearly every row. +See [../data-model.md](../data-model.md) for the per-selection predicate table. + +### Side effects on other fields + +| Field | Effect when `status` is non-empty | +|---|---| +| `showFolders` | **Unaffected.** The endpoint honours whatever the caller sent | +| `archived` | Unaffected and unchanged. The legacy inclusive flag keeps its meaning (FR-008) | + +**`status` has no side effects on other fields.** Folders carry no status, so the Content Drive UI +sends `showFolders: false` once a status is selected (FR-015) — but that is the client's decision. +Overriding an explicit `showFolders: true` server-side would make the response stop matching the +request, and would leave `folderCursor` / `hasMoreFolders` describing a folder query the caller never +received. A caller that wants folders alongside a status gets them. + +The Content Drive UI stops sending `archived: false` altogether (FR-019); the server default already +supplies it. + +--- + +## Responses + +### 200 — success + +Response shape is unchanged (`ResponseEntityView`). Only the contents of `list`, +and the counts, change. + +### 400 — unrecognized status value + +```jsonc +// request +{ "assetPath": "//demo.dotcms.com/", "status": ["ARCHIVED", "DRAFT"] } +``` + +Returns `400` with a message naming the accepted values. Silently ignoring the unknown entry would +return a **wider** result set than the caller asked for, which is worse than failing. + +Consistent with the existing `userSearchable` rejection in `ContentDriveHelper` — the same +`BadRequestException` path, thrown explicitly rather than left to Jackson. + +### Other statuses + +Unchanged: `401` unauthenticated, `403` no portlet access, `500` unexpected. + +--- + +## Query-path guarantees + +The same `status` selection must return the same set regardless of which search strategy the +environment runs (FR-009 / SC-005). Both paths are in scope: + +| `BROWSE_API_HEURISTIC_TYPE` | Path | How `status` applies | +|---|---|---| +| `HYBRID_SINGLE_CHUNKED_QUERY_ES` (**default**) | `BrowserAPIImpl.selectQuery` supplies the candidate set; the index only narrows by text | SQL clauses — so they apply with **and** without a keyword | +| `PURE_ES` | `BrowserAPIImpl.buildPureESQuery` | Index terms, replacing the hardcoded `+deleted:false` | + +### An empty `status` MUST be ignored entirely + +An omitted or empty `status` changes **nothing** on either path — the field is skipped, not +translated into a vacuous clause. Both builders MUST return early on an empty set rather than +opening a group they have nothing to fill: `and ( )` is a SQL syntax error and `+()` is invalid +Lucene. This is what makes FR-002's "byte-identical to today" literally true, and it is the case to +assert first, because every existing caller of drive search sends no `status`. + +### Multiple statuses MUST be one explicit OR group + +In Lucene, `+` means REQUIRED. Emitting `+deleted:true +live:false` is an **AND** — the opposite of +this contract. Multiple statuses go in one explicit group, with the archived baseline left outside +it as its own required clause: + +| Selection | Index query | +|---|---| +| `[]` | *(no status terms at all)* | +| `["UNPUBLISHED"]` | `+deleted:false +(live:false)` | +| `["UNPUBLISHED","LOCKED"]` | `+deleted:false +(live:false OR locked:true)` | +| `["ARCHIVED"]` | `+(deleted:true)` | +| `["ARCHIVED","LOCKED"]` | `+(deleted:true OR locked:true)` | + +This is already the convention in the method being changed — `BrowserAPIImpl:630` writes +`+(conhost: OR conhost:SYSTEM_HOST)`. + +Aligned with [ADR-0018](https://github.com/dotCMS/platform-adrs/blob/main/decisions/0018-database-first-content-drive-search-with-index-deferred-text-filtering.md), +which routes version-info flags (archived/deleted) to the **database**. `PURE_ES` is patched not to +promote it, but because it is a supported configuration where the filter would otherwise silently +no-op. + +### One accepted divergence under `PURE_ES` + +`UNPUBLISHED` does **not** mean quite the same thing on the two paths, and the difference is +accepted rather than fixed. + +| Path | Predicate | Question it answers | +|---|---|---| +| SQL (default) | `cvi.live_inode is null` | does this **content** have a live version anywhere? | +| Index (`PURE_ES`) | `live:false` | is **this version** the live one? | + +The index stores `live` per version, so a published item that also has newer unpublished edits has a +working document carrying `live:false` — which the index query matches and the SQL query does not. +Under `PURE_ES`, `UNPUBLISHED` therefore returns that item as well. + +**The identifier-scoped meaning is the definition** (see `ContentStatus.UNPUBLISHED`). The index +simply cannot express it: "does any version of this identifier have `live:true`?" is not answerable +from a single document. + +This is not a new limitation. ADR-0018 routes structural predicates to the database precisely +because the index cannot answer them reliably, and states that `PURE_ES` forfeits that guarantee for +*every* criterion and must not become the default. `PURE_ES` is opt-in and is not set in any config +in the repository. `ARCHIVED` and `LOCKED` are unaffected, as is the default path. + +--- + +## OpenAPI + +`openapi.yaml` is generated by `swagger-maven-plugin` at compile. The field's description goes in the +Java annotations; the regenerated +`dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml` is committed alongside: + +```bash +./mvnw compile -pl :dotcms-core -DskipTests +git diff -- '*openapi.yaml' +``` + +CI verifies the committed file matches what the build produces. + +--- + +## Frontend contract + +`DotContentDriveSearchRequest.status?: string[]` in +`core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts`. + +The value lives in the shared `filters` bag rather than its own query param, so it inherits every +navigation mechanism the other filters already use — deep link, reload, folder browsing, browser +Back/Forward, and the legacy-editor round-trip (FR-016): + +``` +…?filters=languageId:1;sharedAssets:true;status:UNPUBLISHED,LOCKED +``` + +Encoding needs no new code — `encodeFilters` already comma-joins array values. Decoding is one entry +in `decodeByFilterKey` (`status: multiSelector`), which is **required**: without it a single-value +URL (`status:ARCHIVED`) falls through to the comma sniff and decodes as a string rather than an +array. diff --git a/specs/37066-content-drive-status-filter/data-model.md b/specs/37066-content-drive-status-filter/data-model.md new file mode 100644 index 000000000000..340c9a5e85c5 --- /dev/null +++ b/specs/37066-content-drive-status-filter/data-model.md @@ -0,0 +1,278 @@ +# Phase 1 Data Model: Content Drive Status Filter + +**Feature**: [spec.md](./spec.md) | **Date**: 2026-08-24 + +This document is self-contained: every decision below carries its own reasoning rather than citing +the Spec-Kit process artifacts, which are gitignored by policy (`.specify/CUSTOMIZATIONS.md`) and so +are not reviewable from this diff. + +No database schema changes. Every column this feature reads already exists on +`contentlet_version_info` and is already indexed into the search index. This document describes the +**in-memory and over-the-wire shapes** the feature introduces. + +--- + +## Entity: `ContentStatus` (new) + +`dotCMS/src/main/java/com/dotcms/browser/ContentStatus.java` + +A closed enum of three independent states a contentlet version can hold. + +| Constant | Meaning | Backing column (`contentlet_version_info`) | Index term | +|---|---|---|---| +| `ARCHIVED` | Removed from circulation, recoverable | `deleted = true` | `deleted:true` | +| `UNPUBLISHED` | No live version exists | `live_inode is null` | `live:false` | +| `LOCKED` | A lock is held, by anyone | `locked_by is not null` | `locked:true` | + +> **The index terms above are bare on purpose — do not prefix them with `+`.** In Lucene syntax `+` +> means REQUIRED, so emitting `+deleted:true +live:false` is an **AND**, which is the exact opposite +> of this feature's semantics. Multiple statuses MUST be wrapped in one explicit group: +> `+(deleted:true OR live:false)`. This is already the convention in the method being changed — +> `BrowserAPIImpl:630` writes `+(conhost: OR conhost:SYSTEM_HOST)`. See the composed forms under +> [Query shape](#query-shape-browserquerycontentstatuses). + +**Placement**: `com.dotcms.browser` rather than the REST package — `BrowserQuery` is the consumer, +and the browser layer must not depend on the REST layer. Sits alongside `FieldSearchCriteria`, which +plays the same query-shaping role. + +**Relationships**: none. The three are orthogonal facts about one row. + +**Not a state machine**: an item can hold any subset of the three at once. The filter asks whether +an item is in *any* selected state, not all of them — selected statuses combine with **OR**, like +the Content Type and Language filters. AND was considered and rejected: under AND, +`{ARCHIVED, UNPUBLISHED}` is redundant, `{ARCHIVED, LOCKED}` is almost always empty and all three is +empty in practice, so only one of four combinations says anything — and the chip would be the sole +exception in a toolbar row where every other filter widens on selection. + +One overlap is worth knowing even though it no longer produces a degenerate result: every archived +item is also unpublished, because archiving removes the live version +(`ESContentletAPIImpl.java:3833`). Under OR that just means `{ARCHIVED, UNPUBLISHED}` reads as +"everything with no live version" rather than being redundant. + +--- + +## Transport shape: `DriveRequestForm.status` + +`dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java` + +```java +@JsonProperty("status") +@Value.Default +default List status() { return List.of(); } +``` + +| Property | Value | +|---|---| +| JSON key | `status` | +| Type on the wire | array of strings | +| Accepted values | `ARCHIVED`, `UNPUBLISHED`, `LOCKED` (case-insensitive on input, uppercased before lookup) | +| Default | `[]` — preserves today's behavior exactly (FR-002) | +| Duplicates | Collapsed; the parsed result is a `Set` | +| Unknown value | `400`, message naming the accepted values (FR-010) | + +**Declared as `List`, not `List`.** The requirement (FR-010) is a 400 on an +invalid value, "consistent with how `userSearchable` rejects unknown keys" — and that precedent is an +explicit `BadRequestException` thrown inside `ContentDriveHelper.driveSearch`. Declaring the field as +the enum would instead let the Immutables/Jackson layer reject it during deserialization, as an +`InvalidFormatException` whose mapping to a 400 with a message naming the accepted values is not +under this code's control. So the helper owns the parse: one error path, one message style, one +status code, matching the filter that already does this. The typed field would read better; the +deterministic error is worth more. + +### Validation rules + +| Rule | Source | Enforced in | +|---|---|---| +| Empty is valid and means "no status filtering" | FR-001, FR-002 | `ContentDriveHelper` (block is skipped) | +| Every element must name a `ContentStatus` | FR-010 | `ContentDriveHelper.parseStatuses` → `BadRequestException` | +| Selection widens (OR), never narrows | FR-006 | `BrowserAPIImpl.appendContentStatusQuery` — one OR-ed group | +| The archived baseline stands unless `ARCHIVED` is selected | FR-007 | `BrowserAPIImpl:2006` — the exclusion is skipped only when the selection contains `ARCHIVED` | +| A non-empty selection excludes folders **in the UI** | FR-015 | the store's `$request`, not the endpoint — see below | + +### Folders are the client's call, not the endpoint's + +Folders carry no status, so the Content Drive UI drops them once a status is selected — the +`showFolders` conjunction in the store's `$request` already does this alongside `baseType`, +`contentType` and `workflow`. + +The **endpoint deliberately does not enforce it.** Forcing `showFolders` to false server-side would +be a silent side effect: the response would stop matching the request, and `folderCursor` / +`hasMoreFolders` would report on a folder query the caller never received. A caller that asks for +folders alongside a status receives them. + +*(Note: the pre-existing workflow filter still forces `showFolders(false)` in `ContentDriveHelper`. +The two filters therefore differ. Reconciling that is outside this feature.)* + +### The archived baseline is not a fourth flag, and it lives outside the OR group + +Excluding archived content is the drive's **pre-existing default**, not a member of this set: +`appendExcludeArchivedQuery` already emits `cvi.deleted = false` on every request today. The status +group is OR-ed internally and AND-ed against that baseline; `ARCHIVED` is the only status that lifts +it. + +| Selection | Baseline | Status group | Net | +|---|---|---|---| +| `[]` | `deleted = false` | — | today's behavior | +| `[UNPUBLISHED]` | `deleted = false` | `(live_inode is null)` | unpublished, not archived | +| `[LOCKED]` | `deleted = false` | `(locked_by is not null)` | locked, not archived | +| `[UNPUBLISHED, LOCKED]` | `deleted = false` | `(live_inode is null or locked_by is not null)` | either, still not archived | +| `[ARCHIVED]` | *lifted* | `(deleted = true)` | archived only | +| `[ARCHIVED, UNPUBLISHED]` | *lifted* | `(deleted = true or live_inode is null)` | everything with no live version | +| all three | *lifted* | `(deleted = true or live_inode is null or locked_by is not null)` | anything not cleanly published | + +### Composed query forms + +The SQL group and the index group are the same shape: the selected statuses OR-ed inside one group, +AND-ed against the archived baseline that sits outside it. + +| Selection | SQL | Index | +|---|---|---| +| `[]` | *(no status clause at all)* | *(no status clause at all)* | +| `[UNPUBLISHED]` | `and cvi.deleted = false and ( cvi.live_inode is null )` | `+deleted:false +(live:false)` | +| `[UNPUBLISHED, LOCKED]` | `and cvi.deleted = false and ( cvi.live_inode is null or cvi.locked_by is not null )` | `+deleted:false +(live:false OR locked:true)` | +| `[ARCHIVED]` | `and ( cvi.deleted = true )` | `+(deleted:true)` | +| `[ARCHIVED, LOCKED]` | `and ( cvi.deleted = true or cvi.locked_by is not null )` | `+(deleted:true OR locked:true)` | + +**An empty selection must emit nothing at all** — not an empty group. `and ( )` is a SQL syntax +error and `+()` is invalid Lucene, so both builders MUST return early on an empty set rather than +opening a group they then have nothing to fill. This is what makes FR-002's "byte-identical to +today" literally true. + +**Filters AND with each other; only values within one filter OR.** A status selection combined with +the workflow filter means *governed by that workflow* **and** *in any of the selected states* — each +filter appends its own `and ( … )` clause (`BrowserAPIImpl:2338` for workflow), exactly as content +type and locale already compose today. + +**The bug to avoid** is folding the baseline into the group. `[UNPUBLISHED, LOCKED]` would then read +`(deleted = false or live_inode is null or locked_by is not null)`, which matches essentially every +row in the folder — a filter that silently stops filtering. + +Note that a single-status selection produces a one-disjunct group, so `[ARCHIVED]` is still exactly +`cvi.deleted = true`. OR and AND only diverge from two statuses upward. + +*(The baseline-vs-flag distinction was raised by the automated spec review on +[#37170](https://github.com/dotCMS/core/pull/37170); the OR semantics were settled separately during +planning, see the OR rationale above.)* + +### `LOCKED` and version scoping compose + +`LOCKED` does not constrain which version is joined, so it stacks on whatever `showWorking` already +selected. That is the same pairing the legacy portlet uses: `ContentletAjax.java:1018` appends +`+locked:true` and `:1035` unconditionally appends `+working:true`. (Those legacy terms carry `+` +because legacy genuinely does AND its status flags — do **not** copy that form here; see the note +under the enum table.) + +One deliberate difference: legacy **always** scopes to the working version, whereas here the drive +scopes to working because `AbstractDriveRequestForm.live()` defaults to `false`. A caller that sets +`live: true` with `status: ["LOCKED"]` therefore gets "live content that is locked" — a coherent, +strictly more expressive query, not a bug. Only `ARCHIVED` and `UNPUBLISHED` force working-version +scoping, because neither state can have a live version at all. + +--- + +## Query shape: `BrowserQuery.contentStatuses` + +`dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java` + +```java +final Set contentStatuses; // never null; empty means no filtering +public Set getContentStatuses() // accessor, mirrors getFieldCriteria() +Builder withContentStatuses(@Nonnull Set) +``` + +Plumbed exactly like `workflowSchemeIds`: builder field (`LinkedHashSet`, insertion-ordered for +stable generated SQL), `Set.copyOf` in the constructor, a line in the copy-constructor, and a line +in `toString()`. + +**One derived field changes.** The constructor's + +```java +this.showWorking = builder.showWorking || builder.showArchived; +``` + +must also be true when the selection contains `ARCHIVED` or `UNPUBLISHED`. + +`selectQuery` picks the joined inode column from this flag +(`BrowserAPIImpl.java:1947`: `showWorking || showArchived ? "working_inode" : "live_inode"`), and the +base query joins `c.inode = cvi.` (`:2043`). Archived and unpublished rows have **no +live version by definition**, so under `live_inode` the join can never match and the filter returns +nothing — silently, with no error. The same flag also drives `buildPureESQuery`'s `+working:true` vs +`+live:true` (`:615`), where emitting `+live:true` alongside a `live:false` disjunct would be +self-contradicting. + +The Content Drive path happens to be safe today because the form's `live()` defaults to `false`, but +that is a coincidence in one caller, not a property of `BrowserQuery`. `LOCKED` alone does not need +this: a locked item may well have a live version. + +--- + +## Frontend shape + +### Filter-bag entry + +`core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts` + +```ts +export type DotKnownContentDriveFilters = { + // … + status: string[]; // 'ARCHIVED' | 'UNPUBLISHED' | 'LOCKED' +}; +``` + +| Aspect | Behavior | Why | +|---|---|---| +| URL encoding | `status:ARCHIVED,LOCKED` | `encodeFilters` already comma-joins arrays — no change | +| URL decoding | `status: multiSelector` in `decodeByFilterKey` | one line; splits on comma | +| Seeded default | **No** | Empty genuinely means "off", unlike `languageId`/`sharedAssets` | +| "Clear all" | Cleared automatically | `clearFilters()` re-seeds only defaults, so `status` drops | +| Chip visibility | Automatic | `hasNonDefaultFilters` returns `true` for any non-default key | + +### Request field + +`core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts` + +```ts +export interface DotContentDriveSearchRequest { + // … + status?: string[]; +} +``` + +Sent only when non-empty. The `archived: false` pin is **removed** — the form's own `archived()` +already defaults to `false`, so omitting it produces an identical query while letting the status +selection own the archived decision (FR-019). + +### Option list + +`core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts` + +```ts +export const STATUS_FILTER_KEY = 'status'; + +export const CONTENT_STATUS = { + ARCHIVED: 'ARCHIVED', + UNPUBLISHED: 'UNPUBLISHED', + LOCKED: 'LOCKED' +} as const; + +export const STATUS_FILTER_OPTIONS: { value: string; labelKey: string }[] = [ + { value: CONTENT_STATUS.ARCHIVED, labelKey: 'content-drive.status-filter.archived' }, + { value: CONTENT_STATUS.UNPUBLISHED, labelKey: 'content-drive.status-filter.unpublished' }, + { value: CONTENT_STATUS.LOCKED, labelKey: 'content-drive.status-filter.locked' } +]; +``` + +Shape follows `FOLDER_UPLOAD_BEHAVIOR_OPTIONS` in the same file. Order is display order: Archived +first because it is the capability people currently leave Content Drive to get (US1). + +--- + +## What this feature does **not** change + +- **No schema migration.** `deleted`, `live_inode` and `locked_by` all predate this work. +- **No index mapping change.** `deleted`, `live` and `locked` are already mapped + (`ESMappingAPIImpl.java:527` for `locked`). +- **No change to `BrowserQuery.showArchived`.** Its inclusive meaning and the legacy Site Browser + checkbox that depends on it are untouched (FR-008). +- **No new API surface.** One optional field on an existing request body; `openapi.yaml` is + regenerated, not hand-edited. diff --git a/specs/37066-content-drive-status-filter/spec.md b/specs/37066-content-drive-status-filter/spec.md new file mode 100644 index 000000000000..99b9bfe1c45a --- /dev/null +++ b/specs/37066-content-drive-status-filter/spec.md @@ -0,0 +1,345 @@ +# Feature Specification: Content Drive Status Filter + +**Feature Branch**: `issue-37066-content-drive-status-filter` + +**Created**: 2026-08-24 + +**Status**: Draft + +**Type**: New Feature (Task) + +**GitHub Issue**: [dotCMS/core#37066](https://github.com/dotCMS/core/issues/37066) (absorbed [#37067](https://github.com/dotCMS/core/issues/37067); parent epic [#33999](https://github.com/dotCMS/core/issues/33999)) + +**Input**: User description: "Content Drive needs a Status filter (Archived, Unpublished, Locked): the search clauses on the drive search endpoint and the multiselect in the toolbar. None of the three predicates is expressible on the endpoint today. Nothing selected must mean exactly today's behavior: archived hidden, everything else returned." + +--- + +## Scope Note *(read this first)* + +This is **one vertical slice**: the search capability and the control that drives it ship together. +The ticket originally split them across two issues; #37067 was merged into #37066 because each half +restated the same contract and duplicated contract text is where drift starts. + +**Selected statuses combine additively (OR), exactly like the Content Type and Language filters +beside it.** Checking more boxes returns more content, never less. `Archived + Unpublished` means +"archived **or** unpublished" — everything with no live version — not the intersection of the two. + +This is a deliberate reversal of the ticket's original wording, which specified AND. The argument +for AND was that these are independent flags one item can hold at once, so intersecting them is +meaningful. It is — but only for one of the four possible combinations. Under AND, +`Archived + Unpublished` is redundant (archiving removes the live version, so every archived item is +already unpublished), `Archived + Locked` is almost always empty, and all three together is empty in +practice. Only `Unpublished + Locked` says something useful. Under OR every combination is +meaningful, and the control stops behaving as the sole exception in a row of filters that all widen +when you check more boxes — a difference no UI affordance can convey and that a user would read as +a bug. + +The cost is accepted and recorded in Assumptions: "unpublished **and** locked" is no longer +expressible in Content Drive. + +--- + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - An editor finds content that was archived (Priority: P1) + +An editor is looking for a page a colleague archived last week, to check what it said before +deciding whether to restore it. Content Drive hides archived content by default, so today the only +way to see it is to leave Content Drive for the legacy Content Search portlet. The editor selects +**Archived** and the drive lists archived items — and only archived items. + +**Why this priority**: This is the capability people currently leave Content Drive to get. It is +also the only one of the three that is *partially* present today in a form that does the wrong +thing (a flag that returns archived content **plus** everything else), so shipping it correctly is +what makes the filter trustworthy. + +**Independent Test**: Archive one item in a folder that also holds live and draft items. Select +Archived. The result set contains the archived item and nothing else. + +**Acceptance Scenarios**: + +1. **Given** a folder holding live, draft and archived items, **When** the editor selects Archived, + **Then** only the archived items are listed. +2. **Given** Archived is selected, **When** the editor clears it, **Then** the drive returns to + hiding archived content, exactly as before the filter existed. +3. **Given** Archived is selected, **When** the editor also types a keyword in the search box, + **Then** the results are archived items matching that keyword — the two filters narrow together. + +--- + +### User Story 2 - An editor reviews what is not live yet (Priority: P1) + +Before a release an editor wants to see everything in a section that has never been published or +whose published version has been taken down. They select **Unpublished** and the drive lists content +with no live version. Archived items do not appear: archived content is a separate question, and an +editor auditing drafts is not asking about the recycle bin. + +**Why this priority**: "What is not live?" is the most common pre-release question content teams +ask, and Content Drive cannot express it at all today. The existing live/working switch answers a +different question (show me the live version) and is not the inverse of this one. + +**Independent Test**: Create a never-published item, publish a second, archive a third. Select +Unpublished. Only the never-published item is listed. + +**Acceptance Scenarios**: + +1. **Given** a folder with published, unpublished and archived items, **When** the editor selects + Unpublished, **Then** only the unpublished, non-archived items are listed. +2. **Given** an item that was published and then unpublished, **When** the editor selects + Unpublished, **Then** that item is listed. + +--- + +### User Story 3 - A manager finds content someone has checked out (Priority: P2) + +A content manager notices work is stalled and wants to see everything currently locked. They select +**Locked** and the drive lists items with a lock held, whoever holds it, so the manager can chase +the owner or unlock the item. + +**Why this priority**: Real and frequently asked, but it is a supervisory question rather than part +of the daily editing loop, and there is a workaround today (open items one at a time and look at the +lock indicator). It also has no partial implementation to correct, so it is pure addition. + +**Independent Test**: Lock one item in a folder of otherwise unlocked items. Select Locked. Only the +locked item is listed. + +**Acceptance Scenarios**: + +1. **Given** a folder with one locked item, **When** the manager selects Locked, **Then** only that + item is listed. +2. **Given** the lock is released, **When** the manager reloads the filtered view, **Then** the item + no longer appears. + +--- + +### User Story 4 - Seeing everything that is not cleanly published (Priority: P2) + +Before handing a section over, a lead wants one view of everything needing attention: drafts, +archived items and anything checked out. They select all three statuses and the drive lists content +in *any* of those states, rather than making them run three separate passes and reconcile the +results by hand. + +**Why this priority**: This is the reason the control is a multiselect rather than a dropdown. Each +status alone is already useful (stories 1–3), so this builds on them rather than standing alone — +but the union is the question a lead actually asks at review time, and no single status answers it. + +**Independent Test**: Seed one archived, one unpublished, one locked and one plain live item. Select +all three statuses. The first three are listed and the live one is not. + +**Acceptance Scenarios**: + +1. **Given** items in each of the three states plus a clean live item, **When** all three statuses + are selected, **Then** every item except the clean live one is listed. +2. **Given** Unpublished is selected, **When** the editor also selects Locked, **Then** the results + *widen* to include locked content that is not unpublished. +3. **Given** two statuses are selected, **When** one is cleared, **Then** the results narrow to the + remaining status alone. + +--- + +### User Story 5 - A filtered view survives navigation and can be shared (Priority: P3) + +An editor sends a colleague a link to "everything unpublished in Marketing". The colleague opens the +link and sees the same filtered view, with the Status selection shown as an active chip. Browsing +into a subfolder keeps it. Browser Back returns to the previous view with the right filters. Opening +an item in the editor and coming back keeps it. Reloading keeps it. Clearing all filters removes it +along with everything else. + +**Why this priority**: Every other Content Drive filter behaves this way, so a Status filter that +did not would read as broken. It is P3 only because the filter is useful before it is shareable. + +**Independent Test**: Select two statuses, navigate into a subfolder, press Back, then reload. The +selection and the results are the same at every step. + +**Acceptance Scenarios**: + +1. **Given** a Status selection, **When** the page is reloaded, **Then** the selection and results + are unchanged. +2. **Given** a Status selection, **When** the editor browses into another folder, **Then** the + selection still applies in that folder. +3. **Given** a Status selection, **When** the editor uses browser Back or Forward, **Then** the + restored view carries the selection that URL had. +4. **Given** a Status selection, **When** the editor opens an item in the editor and returns, + **Then** the selection is still applied. +5. **Given** a Status selection, **When** "Clear all" is used, **Then** the Status selection is + removed along with the other filters. +6. **Given** a Status selection, **When** the view is shared as a link, **Then** the recipient sees + the same selection. + +--- + +### Edge Cases + +- **Folders have no status.** Whenever any status is selected the results are content only. This + matches how the drive already behaves for the other narrowing filters. +- **No status selected** must produce exactly the behavior that exists today — archived content + hidden, everything else returned — with no change to result counts, ordering or pagination. +- **Archived is the only status that reveals archived content.** Selecting Unpublished or Locked + alone must not surface archived items, even though every archived item is technically also + unpublished. Hiding archived content is the drive's standing default, and only Archived lifts it. +- **An unrecognized status value** submitted directly to the search endpoint is rejected with a + clear client error naming the accepted values, rather than being silently ignored (which would + return a wider result set than the caller asked for). +- **Status combined with a workflow filter.** The two combine with AND: *governed by that workflow* + **and** *in any selected state*. Content Drive can already filter by workflow, including steps that + archive content, so the pairing must return a coherent result rather than an empty one caused by + two rules contradicting each other about archived content. +- **Text search plus status.** The drive uses different search strategies depending on whether a + keyword is present and how the environment is configured. Every strategy must apply the status + filter identically, so the same selection never returns different results because of a + configuration the user cannot see. +- **An empty result is still possible** — a folder with no content in any selected state. That shows + the standard empty state, never an error. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The drive search capability MUST accept a set of content statuses drawn from + Archived, Unpublished and Locked, defaulting to an empty set. +- **FR-002**: An empty set MUST preserve today's behavior exactly: archived content excluded, all + other content returned. The status filter MUST be **skipped entirely** in that case, not + translated into a vacuous "matches anything" condition — every search that exists today sends no + status, so this is the default path, not an edge case. +- **FR-003**: Archived alone MUST return only archived content, never archived content in addition + to everything else. +- **FR-004**: Unpublished alone MUST return only content with no live version, and MUST exclude + archived content. +- **FR-005**: Locked alone MUST return only content on which a lock is held, regardless of who holds + it, and MUST exclude archived content. +- **FR-006**: Multiple selected statuses MUST combine with **OR** — the result is content holding + *any* of the selected states. Adding a status MUST never reduce the result set. +- **FR-006a**: OR applies **within** the status filter only. Status MUST still combine with every + other filter by **AND**, as the existing filters already do with each other. Selecting a workflow + and two statuses means *governed by that workflow* **and** *in either of those states* — adding a + status MUST never loosen another filter. +- **FR-007**: Archived MUST be the only status that admits archived content into the results. + Selecting it alongside others widens the results to include archived content as well as content in + the other selected states. +- **FR-008**: The existing inclusive "show archived" behavior relied on by the legacy Site Browser + MUST be left unchanged; the exclusive Archived behavior is added alongside it. +- **FR-009**: A status selection MUST produce identical results whether or not a keyword search is + active, under the default search strategy. +- **FR-009a**: Under the index-only strategy (`PURE_ES`, opt-in and not the default), *Unpublished* + MAY additionally return content that has a live version alongside newer unpublished edits. This is + an accepted divergence, not a defect: *Unpublished* means "no live version exists", which is a + question about the content as a whole, while the index records that flag per version — so a + published item's draft version reads as not-live. The identifier-scoped meaning is the definition; + the index cannot express it in a single-document query. + - This follows [ADR-0018](https://github.com/dotCMS/platform-adrs/blob/main/decisions/0018-database-first-content-drive-search-with-index-deferred-text-filtering.md), + which routes structural predicates to the database precisely because the index cannot answer + them reliably, and states that the index-only strategy forfeits that guarantee for *every* + criterion and must not become the default. This is one instance of a limitation that decision + already accepted, not a new one introduced here. + - Every other status is unaffected, and the default strategy is unaffected. +- **FR-010**: An unrecognized status value MUST be rejected with a client error, consistent with how + the drive already rejects unknown field-filter keys. +- **FR-011**: A status selection MUST NOT conflict with a workflow filter that also constrains + archived content; the two MUST combine into one coherent result set. +- **FR-012**: Users MUST be able to select any combination of the three statuses from a single + control in the Content Drive toolbar, positioned **after the workflow filter and before the locale + filter**. Content type and workflow MUST stay adjacent: the workflow filter's options are derived + from the content-type selection, and that is the only such dependency in the row. Status depends on + nothing, so it sits beside workflow — the two ask the same kind of question, where content sits in + its lifecycle and whether anyone is holding it — without coming between workflow and the selection + it reads from. +- **FR-013**: The control's labels MUST be localizable, following the Content Drive naming + convention already used by the other filters. +- **FR-014**: The active selection MUST be reflected as a chip, consistent with the other toolbar + filters, and MUST be clearable from that chip. +- **FR-015**: Folders MUST be excluded from the Content Drive results whenever any status is + selected. This is a **client-side** rule: folders carry no status, so the Content Drive UI stops + requesting them. The search capability itself MUST NOT override an explicitly requested + folder setting — a caller that asks for folders alongside a status MUST receive them. Silently + overriding it would make the response stop matching the request, and would leave the folder + pagination describing a query the caller never received. +- **FR-015a**: Because the rule is client-side, changing it MUST remain a client-side change. If + folder visibility is later exposed as its own control, honouring it MUST NOT require altering the + search capability or its contract. +- **FR-016**: A status selection MUST persist across navigation exactly as every other Content Drive + filter does: deep link, page reload, browsing between folders, browser Back/Forward, and opening + an item in the editor and returning. +- **FR-017**: The existing "Clear all" action MUST clear the status selection. +- **FR-018**: An empty result set MUST show the standard empty state, never an error. +- **FR-019**: The Content Drive request MUST stop pinning archived content off unconditionally; that + decision MUST come from the status selection instead. +- **FR-020**: The control and each of its options MUST carry stable test identifiers, and the + control MUST carry an accessible label. + +### Key Entities + +- **Content Status**: An independent state an item can hold, from a closed set of three — *Archived* + (removed from circulation but recoverable), *Unpublished* (no live version), and *Locked* (checked + out by a user). An item may hold several at once, but the filter asks whether an item is in *any* + selected state, not all of them. +- **Status Selection**: The set of statuses the user has chosen. Empty by default; every member + widens the result set. + +## Success Criteria *(mandatory)* + +> **How these are verified.** This specification stays technology-agnostic by convention, so the +> concrete test types live in the plan: **unit** for status parsing and the 400, **integration** for +> the semantics matrix (each status, each pair, all three, the empty default, the never-shrinks +> property, `PURE_ES` parity, and the archive-step regression), and **Jest/Spectator** for the chip, +> the request payload and the URL round-trip. Postman is deliberately not used here — the behavior +> needs seeded archived/locked fixtures that a collection cannot construct against a shared +> environment, and the integration tests cover the same endpoint more precisely. +> +> Per Constitution Principle V these are non-negotiable and land **before** implementation: +> `/speckit-tasks` orders every user story as tests → developer-approval gate → confirmed-failing +> (Red) gate → implementation. + +### Measurable Outcomes + +- **SC-001**: An editor can locate archived content from within Content Drive in a single action, + without leaving for another part of the product. +- **SC-002**: Each single status returns exactly the items in that state and no others, verified + against a known fixture covering all three states plus unaffected content. +- **SC-003**: Every pair of statuses, and all three together, return exactly the union of the items + in the selected states — never fewer items than any one of them alone returns. +- **SC-004**: With no status selected, result counts and ordering are identical to those produced + before the filter existed, for the same folder and filters. +- **SC-005**: The same status selection returns the same result set with and without a keyword + search under the default strategy. Under the opt-in index-only strategy, parity holds for every + status except the *Unpublished* case described in FR-009a. +- **SC-006**: A status-filtered view reproduces the same selection and results after a reload, a + folder change, a Back/Forward, an editor round-trip, and when opened by a second user from a + shared link. +- **SC-007**: Selecting an additional status never returns fewer results than the selection did + before it was added. + +## Legacy Considerations *(dotCMS-specific — mandatory)* + +- **Existing behavior touched**: The shared content-browsing capability behind both Content Drive + (modern) and the Site Browser (legacy). The legacy Site Browser exposes a "Show Archived" checkbox + whose meaning is *inclusive* — archived content **in addition to** everything else. The new + Archived status is *exclusive* — archived content **only**, when selected alone. These are + different questions and both must remain expressible; the new behavior is added alongside the old + one rather than replacing it. + - The legacy Content Search portlet offers the same three predicates but combines them + **additively in the AND sense** on its backend, while exposing them as a mutually exclusive + dropdown in its UI. This spec deliberately follows neither: it keeps the multiselect but makes + it a union, because that is what matches the rest of the Content Drive toolbar. +- **Backward-compatibility expectations**: The legacy Site Browser's "Show Archived" checkbox must + behave exactly as before. Existing callers of the drive search capability that send no status must + see byte-identical results. No content, stored configuration, or admin workflow changes. +- **Known related decisions**: The workflow filter delivered earlier in this epic established how a + new drive-search filter is plumbed end to end, and the later archive-step work established the + precedent for a filter that manipulates the archived condition — including the care needed when two + filters both have an opinion about it. Both are binding shape precedents. The plan phase will + formally consult `dotCMS/platform-adrs`. + +## Assumptions + +- The three statuses are the complete set for this feature. Other states an item can be in (for + example "has a scheduled publish date") are out of scope. +- "Locked" means a lock is held by anyone, not "locked by me". A per-user variant is not requested + and would be a separate filter. +- **Intersective queries are out of scope.** "Unpublished **and** locked" — drafts currently checked + out — is not expressible through this control, and this is an accepted trade for a filter row with + one consistent mental model. If it proves to be a real need, it belongs in a later refinement that + makes the combining rule explicit in the UI rather than implicit and inverted. +- Users of this filter already have permission to see the content it surfaces; the filter narrows a + result set that permission checks have already constrained, and grants no new visibility. +- The Shared Assets / System Host toggle is explicitly out of scope, tracked separately in + [#34760](https://github.com/dotCMS/core/issues/34760).