feat(content-drive): Status filter (Archived, Unpublished, Locked), three regression fixes, and a Refresh role gate (#37066) - #37216
Conversation
…cked) (#37066) Adds an optional `status` array to POST /api/v1/drive/search plus a multiselect chip in the Content Drive toolbar. Selected statuses combine with OR: checking more boxes returns more content, matching the content-type and locale filters. Backend - ContentStatus enum (com.dotcms.browser), carried through BrowserQuery exactly like workflowSchemeIds. - appendContentStatusQuery emits ONE OR-ed group, AND-ed against the archived baseline that stays outside it. Folding the baseline in would make [UNPUBLISHED, LOCKED] read (deleted = false or ...) and match nearly every row — a filter that silently stops filtering. - Empty selection emits nothing at all rather than an empty group: `and ( )` is a SQL syntax error and `+()` is invalid Lucene. This is the default path, so an unfiltered request stays byte-identical to before. - buildPureESQuery emits +(a OR b), never concatenated `+` terms — in Lucene `+` means REQUIRED, so `+deleted:true +live:false` would be an AND. Caught in review by @nollymar. - ARCHIVED admits archived rows the same way showArchived does, so it suppresses the archive-target-step reconciliation instead of contradicting it. - showWorking is now true for ARCHIVED/UNPUBLISHED: neither state has a live version, so the query would otherwise join live_inode and return nothing. - parseStatuses rejects unknown values with a 400 naming the accepted ones. The offending value travels as a format ARGUMENT: HttpStatusCodeException runs String.format over the message, so concatenating user input made `status=50%` raise UnknownFormatConversionException and surface as a 500. Found by the tests; regression guard added. Frontend - New chip, placed after Workflow and before Locale. Content type and workflow must stay adjacent — the workflow filter derives its scheme list from the content-type selection, the row's only real dependency. - Selection lives in the shared filters bag, so deep link, reload, folder browsing, Back/Forward and the legacy-editor round-trip all work unchanged. - decodeByFilterKey needs the explicit `status` entry: without it a lone `status:ARCHIVED` decodes to the STRING 'ARCHIVED', whose .length is 8, and every `?.status?.length` guard silently misreads it. - The listbox owns selection in multiple mode so the whole row — label included — is clickable; the checkbox is presentation only. - Dropped the hardcoded `archived: false` pin; the server already defaults it and pinning it would contradict an Archived selection. Tests - ContentDriveHelperStatusTest: 8/8 green. - ContentDriveStatusFilterTest: 13 cases covering each status, the union, the never-shrinks property, PURE_ES parity and the archive-step regression. NOT YET RUN — needs PostgreSQL + Elasticsearch. - Frontend 1304/1304 green, lint clean, no new tsc errors. - Renamed the `status:published` placeholder in existing decodeFilters tests to `owner:jane`: they used it to exercise unknown-key fallback, and `status` is now a real key. Changing their expectations instead would have destroyed what they test. openapi.yaml is unchanged: /v1/drive has no entries in the generated spec at all (unlike /v1/folder, /v1/browser and /v1/workflow), so the @operation text does not reach it. Pre-existing gap, flagged rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @zJaaal's task in 2m 16s —— View job Code ReviewReviewed the diff against
New IssuesNo issues found. The backend query construction is sound:
Frontend:
Resolved
Notes (non-blocking, already acknowledged in the PR)
The implementation is careful and well-tested. My one caveat is the same one the PR is transparent about: the integration suite's real verification is CI ( |
Inodes carry the version state; identifiers do not. The original assertions
could not catch the bug they were written to guard.
selectQuery selects `cvi.<working_inode|live_inode> as inode`, choosing the
column from `showWorking || showArchived`. So which inode comes back is the
evidence that the right VERSION was joined. An identifier is stable across
versions, so if the showWorking derivation ever stopped covering ARCHIVED, the
query would join live_inode — null for archived content — return nothing, and
every identifier-based assertion would still pass.
- driveIdentifiers() -> driveInodes(), reading item.get("inode").
- workingInode(contentlet) re-reads from VersionableAPI at assertion time.
Avoiding inodes originally because publish/archive/lock mint new versions was
the wrong conclusion: the fix is to re-read, not to drop the precision.
- liveInode(contentlet) so a test can prove there is no live version.
Adds two cases that only an inode-level check can express:
testArchivedReturnsTheWorkingInodeNotTheLiveOne and
testUnpublishedReturnsTheWorkingInode. Both first assert the live inode is null
— otherwise they would pass vacuously — then assert the working inode is what
the drive returned. That makes the showWorking rule an actual guard rather than
a comment claiming one exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both branches had independently merged main, which made them diverge: impl no longer contained spec's tip, so the merge base fell back to a main commit and PR #37216's diff ballooned from 22 files to 46 — sweeping in #37132's edit-content changes, specs/37132-picker-per-host/spec.md and .specify/feature.json. Restoring the parent relationship so the stack's diff is the feature again.
🐳 PR Docker test imageLatest build for commit docker pull dotcms/dotcms-test:pr-37216-issue-37066-content-drive-status-filter-impl
docker pull dotcms/dotcms-test:pr-37216-issue-37066-content-drive-status-filter-impl_aad8e69 |
…#37066) The status filter did nothing. Validation worked — an invalid value still returned the right 400 — but every selection returned the unfiltered result set and folders were never suppressed. Cause: the block mutated the builder AFTER the query had been snapshotted. final BrowserQuery browserQuery = builder.build(); // snapshot builder.withContentStatuses(...).showFolders(false); // discarded return browserAPI.getPaginatedContents(browserQuery); I anchored the insertion on the Logger.debug call below it. Between writing that and merging main, main refactored the method to build the query into a local before logging it, which moved my block from before the build to after it. No compile error, no test failure — the mutation was simply dropped. Fix is the ordering: parse and mutate, then build, then use. Also adds a warning at the build boundary, because nothing in the type system stops this recurring. Found by manual testing against a deployed image, not by the suite. The unit tests cover parseStatuses in isolation and pass either way; the frontend tests never reach the server. ContentDriveStatusFilterTest would have failed on the first assertion — but it has still never been run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… endpoint The status block no longer forces showFolders(false). Folders carry no status, so the Content Drive UI stops asking for them once a status is selected — the store already does this. But making the ENDPOINT override an explicit showFolders:true is a silent side effect: the response stops matching the request, and folderCursor/hasMoreFolders end up describing a folder query the caller never received. Enforcement now lives only on the frontend, where the product decision belongs. The endpoint does what it is told. The integration test is inverted accordingly: it now asserts a status selection does NOT override an explicit showFolders:true, and that showFolders:false still suppresses folders. Note the pre-existing workflow filter still forces showFolders(false) server-side (ContentDriveHelper:224). Left alone — it is outside this ticket — but the two filters now behave differently and that is worth reconciling separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store already sends showFolders:false when a status is selected. This adds the reasoning next to it, so the rule is not "helpfully" pushed back into the endpoint by a later change. POST /drive/search honours whatever showFolders it is sent, deliberately, so the response always matches the request and the folder cursors never describe a query the caller did not make. Keeping the policy on the client also means that if folder visibility ever becomes its own control, honouring it is a change to this one line — no backend refactor, no API contract change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re that distinguishes what UNPUBLISHED means (#37066) Two findings from the automated review on #37216. 1. ContentStatus.valueOf(status.trim().toUpperCase()) used the JVM default locale. Under tr-TR, "unpublished".toUpperCase() is "UNPUBLİSHED" with a dotted capital I, which valueOf rejects — a valid lowercase value became a 400 on Turkish-locale servers only. Now toUpperCase(Locale.ROOT), with a regression test that flips the default locale. 2. The reviewer flagged that UNPUBLISHED may mean different things on the SQL and PURE_ES paths. Confirmed: ESMappingAPIImpl:523 indexes `live` per VERSION (contentlet.isLive()), so a published item with pending working edits has a working document carrying live:false. `+working:true +(live:false)` matches it; SQL's `cvi.live_inode is null` does not. The review suggested verifying with the PURE_ES parity case — but that test does not exist. It was listed in tasks.md T031, the PR body and quickstart.md as covered and was never written; those claims are corrected here. The fixture also had no published-then-edited item, so a parity test would have compared two identical answers and passed for the wrong reason. Adds that discriminating fixture and pins the intended semantics on the default path: a published-then-edited item HAS a live version, so UNPUBLISHED must exclude it — "no live version exists", not "this version is not live". The ES divergence itself is left open: "the identifier has no live version" is not expressible in a per-document index query. Resolving it is a spec decision, recorded in tasks.md rather than guessed at here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both 🟡 findings are real. Fixed in 🟡 Locale — fixed. Exactly right. 🟡 SQL vs PURE_ES The verification you suggested could not have run. There is no The fixture was also blind to it — no published-then-edited item, so a parity test would have compared two identical answers and passed for the wrong reason. That fixture now exists, plus a test pinning the intended semantics on the default path: a published-then-edited item HAS a live version, so The divergence itself is left open deliberately. "The identifier has no live version" is not expressible in a per-document index query —
Recorded in On your non-blocking note about Analysis and reply by Claude (Claude Code), posted from @zJaaal's account. |
…trator role (#36845) The endpoint has always refused a non-admin, but the row did not say so, and the user found out by firing it and reading a 403. `BulkRefreshHelper.canRefresh` reads as two checks ORed together, and the first one never fires: it resolves the role *key* "CMS Power User", no role ships with that key, `loadRoleByKey` answers null and `RoleFactoryImpl` returns false for a null role. What survives is `doesUserHaveRole(user, loadCMSAdminRole())`, which is the same expression as `User.isAdmin()`, which is what feeds `currentUserIsAdmin` here. So the client can predict the refusal exactly rather than guess at it, which is what the original decision log doubted. Disabled rather than hidden, matching Push Publish with no environment: the row still says the capability exists and the tooltip says who it is for. Known limit: an install that hand-created a role keyed "CMS Power User" would let a non-admin holder through server-side while this row stays shut. dotCMS ships no such role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Documentation only, no behaviour change. Both came out of review and both are questions the next reader will ask in the same order. **Why not WorkflowState?** It carries the same three names — LOCKED, UNPUBLISHED, ARCHIVED — so ContentStatus reads like a duplicate on sight. It is not: WorkflowState is the `show_on` vocabulary deciding whether a workflow ACTION renders, which is why it also has LISTING and EDITING, view contexts with nothing to resolve against in a query. Its `toSet` swallows an unparseable value and returns an EMPTY set, so one bad entry drops the whole filter and returns a WIDER result than asked for — the opposite of the 400 this filter contracts for. Reusing it would also tie the public /v1/drive/search input vocabulary to workflow internals, so a new show_on value would silently widen the API. Same words, different job. Now said in the javadoc instead of only in a PR thread. **Why decodeFilters drops every empty array, not just status.** The automated review flagged the comment as narrower than the behaviour, and it was right. The rule is general on purpose: every consumer reads these through `?.length`, so `undefined` and `[]` already mean the same thing, and storing the key would re-encode `status:` into the URL for the next decode to trip over. `status` is only what surfaced it — sanitizing `status:BOGUS` is the first decode that can legitimately produce an empty array. Comment now describes the rule and names status as the motivating case rather than the scope. 175 frontend tests pass, tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing (#37066)" This reverts 65b3dc7. The existing comments already carry their weight; the rationale for why ContentStatus is not WorkflowState, and why decodeFilters drops every empty array rather than only status, belongs in the PR description where reviewers are asking, not layered onto the source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37066) Fixes the deterministic `Frontend Unit Tests` failure. No test was failing — the job died on `FATAL ERROR: Ineffective mark-compacts near heap limit`, with zero FAIL lines in a 459k-line log. The fuel was 3,122 `NG0101: ApplicationRef.tick is called recursively` errors, each carrying a full stack trace. 2,612 of them entered through `Spectator.flushEffects` — from just 37 call sites in the shell spec, ~70 warnings per call. Cause: `TestBed.flushEffects()` is an alias for `TestBed.tick()`, which sets `appRef.includeAllTestViews = true` and ticks EVERY test view, not just the fixture under test. Called while change detection is already running, each view re-enters `tick` and logs. `spectator.detectChanges()` is scoped to the one fixture and does not. Measured on the full project suite: NG0101 2,823 -> 0 log lines 340,126 -> 6,743 (98% less) tests 1,409 pass, unchanged Not introduced here. Clean `origin/main` produces 2,492 of the same errors from a byte-identical shell spec; this PR's extra specs were the straw that crossed the heap limit, not the cause. Main is one added spec away from the same failure in anyone's PR, which is why this is fixed rather than worked around with --max-old-space-size (a flag this repo sets nowhere, and which would hide the defect). `dot-content-drive.store.spec.ts` deliberately keeps its 15 `flushEffects` calls: it is a service spectator with no component fixture, so `detectChanges` cannot flush its effects — swapping them failed 16 tests. It contributes 1 of the 2,823 errors. Scope is this portlet only. The same pattern spans 360 calls across 47 spec files repo-wide; that migration wants its own PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…66-content-drive-status-filter-impl
…66-content-drive-status-filter-impl
Implementation PR, stacked on #37170 (the approved spec) — stack #37217. Review #37170 first; this diff is the feature only.
Resolves #37066.
What it does
An optional
statusarray onPOST /api/v1/drive/search, plus a multiselect chip in the Content Drive toolbar. Selected statuses combine with OR — checking more boxes returns more content, matching the content-type and locale filters beside it.The five things most worth reviewing
1. One OR-ed group, with the archived baseline OUTSIDE it.
Folding the baseline into the group would make
[UNPUBLISHED, LOCKED]read(deleted = false or …)and match nearly every row — a filter that silently stops filtering.2. Lucene needs an explicit group.
+means REQUIRED, so+deleted:true +live:falseis an AND.buildPureESQueryemits+(deleted:true OR live:false). Caught by @nollymar in review on #37170; it follows the convention already in that same method (+(conhost:… OR conhost:SYSTEM_HOST)).3. An empty selection emits nothing at all. Not an empty group —
and ( )is a SQL syntax error and+()is invalid Lucene. This is the default path: every drive search that exists today sends no status, so an unfiltered request stays byte-identical.4.
showWorkingnow coversARCHIVED/UNPUBLISHED. Neither state has a live version, so without this the query joinslive_inodeand returns nothing — silently, with no error.LOCKEDdoesn't need it; a locked item may well be live.5.
ARCHIVEDsuppresses the archive-step reconciliation the same wayshowArchiveddoes, so it can't contradictappendWorkflowQuery's per-branchcvi.deleted.A real bug the tests caught
parseStatusesoriginally built its 400 message withString.formatand handed the result toBadRequestException. ButHttpStatusCodeExceptionrunsString.formatover the message again — sostatus: ["50%"]raisedUnknownFormatConversionExceptionand surfaced as a 500 instead of a 400. User input was reaching a format string. The value now travels as a format argument, with a regression test.The same test also showed
BadRequestException.getMessage()returns"HTTP 400 Bad Request"; the useful text is in theerror-messageheader, which is what a client actually sees.Regressions found while testing, fixed here
Three Content Drive issues surfaced during manual testing of this filter. Carried in rather than deferred, at @zJaaal's call, so the portlet ships whole.
1. A loading dialog could not be closed. The folder dialog's footer sat inside
@if ($formReady()), so while the form loaded there was no Cancel button and no way out but the backdrop. The footer is now outside the guard and Cancel always renders; only the submit button waits on the form.2. The toolbar lost its bottom border. Traced to
border-none!on the toolbar, added to stripp-toolbar's default border on the other three sides. Narrowed toborder-x-0! border-t-0!, which keeps the bottom edge that separates the toolbar from the table. Scoped to Content Drive only.3. The context menu was one undifferentiated list. It now reads as three named groups:
A folder gets the same treatment: an Actions caption, and Delete held apart below a separator. There the caption is unshifted after the fact rather than pushed first — every entry on a folder is permission-gated, so that is the only point where the group is known to be non-empty.
On the caption itself:
p-contextMenuhas no group-label class at all — its style map isitem,separator,submenu,submenuIcon, and it rendersitemsas a flyout. PrimeNG's inline group label (p-menu-submenu-label) lives onp-menu. So the caption is a regular item wearing that class, made inert bydisabled(PrimeNG'sisValidItemskips disabled items, keeping it out of keyboard navigation) pluspointer-events-none(it would otherwise take a click and close the menu). Three utilities cancel what the surrounding styles impose:p-0!so it aligns on the item padding instead of being inset twice,pointer-events-none, andtext-inheriton the content wrapper, since.p-contextmenu-item-contentsetscoloron a descendant and would win.Switching wholesale to
p-menufor its native grouped model was considered and rejected:p-menupopup callsabsolutePosition(container, target)and anchors to an element, whilep-contextMenupositions atevent.pageX/pageY. A right-click menu opening at the row instead of the cursor is a worse regression than a faked caption row.The destructive split reads
hasArchiveActionlet || hasDeleteActionlet || hasDestroyActionlet— the action's actual sub-actionlets, never its name, so a scheme's "Retire this blog" or "Purge" lands there too. A parameterized test names the actions nothing like Archive or Delete precisely to hold that line. A folder's Delete gets the same separator; its gate is strictly narrower than Edit Folder's, so it can never lead the menu.Finer grouping than this is not possible from the data: the API exposes no actionlet class names, no category and no tag,
orderis a within-scheme sort index, andiconis admin-authored free text. Anything more would be guesswork that breaks on custom schemes.Frontend notes
[ngModel]on the listbox without(ngModelChange), so only the checkbox responded. Regression test clicks the label specifically.decodeByFilterKeyneeds the explicitstatusentry. Without it a lonestatus:ARCHIVEDdecodes to the string'ARCHIVED'— whose.lengthis8, so every?.status?.lengthguard misreads it and the filter looks fine until someone selects exactly one status.archived: falsepin removed from the store request (FR-019); the endpoint already defaults it, and pinning it would contradict an Archived selection.Screen.Recording.2026-08-26.at.10.24.34.AM.mov
Two questions reviewers keep asking
Answering both here rather than in the source — the code comments already carry their weight.
Why a new
ContentStatusenum, whenWorkflowStatealready has these names?com.dotmarketing.portlets.workflows.model.WorkflowStateisNEW, LOCKED, UNLOCKED, PUBLISHED, UNPUBLISHED, ARCHIVED, LISTING, EDITING— all three of ours, same spelling. It looks like a duplicate and isn't:show_onset deciding whether a workflow action renders. That is why it also carriesLISTINGandEDITING— view contexts, not states a contentlet can be in, with nothing to resolve against in a query. Reusing it would putstatus: ["EDITING"]in the public API contract.WorkflowState.toSetcatches any exception and returns an empty set, so one bad value silently drops the whole filter and returns a wider result than the caller asked for. This filter deliberately returns a400instead — the behaviour the tests and this PR's review cycle settled on.show_onvalue would silently widen/v1/drive/search's accepted input. We implement three states; that enum has eight and grows for unrelated reasons.Same words, different job.
Why does
decodeFiltersdrop every empty array, not juststatus?Raised by the automated review, and fair: the behaviour is general while the comment reads
status-specific. The general rule is intended.Every consumer reads these through
?.length, soundefinedand[]already mean the same thing — there is no behavioural difference to preserve. Storing the key would re-encode a barestatus:into the URL for the next decode to trip over.statusis simply what surfaced it: sanitizingstatus:BOGUSis the first decode that can legitimately produce an empty array.Verification
ContentDriveHelperStatusTest(unit)portlets-content-drive, full)tsc --noEmitContentDriveStatusFilterTest(integration, 16 cases)ContentDriveWorkflowArchiveStepTest(regression guard)b09fb47— see belowThe integration class covers each status alone, every pair, all three, the empty default, the never-shrinks property and the archive-step regression. Registered in
MainSuite3a.MainSuite 3ahas now run, and it caught one thing — a bad assertion of mine, not a code defect. 1 failure out of 803, deterministic across all three retries:testUnpublishedStatusWithArchiveStepExcludesArchivedContentclaimedUNPUBLISHED+ an archive-target step must exclude archived content. False — an archive-target step makesappendWorkflowQueryadmitcvi.deleted = truerows in that branch by itself, with no status involved (testMixedFilterScopesArchivedToArchiveBranchpins exactly that and passes), andContentletAPI.archiveunpublishes so the row satisfieslive_inode is nulltoo. Both clauses match; returning it is correct.Replaced in
b09fb47by two tests rather than one:…StillReturnsArchivedContentasserts what actually holds, and…KeepsTheGlobalArchivedBaselineis the real complement, using a normal step where nothing lifts the baseline. Strictly stronger than what it replaced — it still guards the baseline and now documents the interaction that misled me.The fix itself was not verified locally:
com.dotcms.tika-api's pom in the local repo is unflattened, so:dotcms-integrationfails dependency resolution before any test runs. CI is the verification.PURE_ESparity holds forARCHIVEDandLOCKED, and deliberately not forUNPUBLISHED— see below.Two things I won't paper over
The TDD gates for US2–US5 are not satisfied. I implemented all three SQL disjuncts in one switch rather than story-by-story, because splitting them leaves states where selecting
LOCKEDparses fine and filters on nothing. Their tests were therefore written against working code — characterization, not true Red. US1's gates were honored properly (Red confirmed, then implementation).UNPUBLISHEDmeans something slightly different underPURE_ES, and we accepted that. It means no live version exists — a question about the content, not about one version. The index storesliveper version, so a published item with newer unpublished edits has a working document carryinglive:false, which the index query matches andcvi.live_inode is nulldoes not. There is no index-side fix short of redefining the status per-version, which would degrade the default path to match a limitation of a strategy nobody runs (PURE_ESis opt-in and is not set in any config file in this repo). ADR-0018 already routes structural predicates to the database for exactly this reason and statesPURE_ESforfeits that guarantee. Recorded as FR-009a in the spec, with FR-009 and SC-005 narrowed to match, and a predicate-comparison table incontracts/.ARCHIVEDandLOCKEDare unaffected.openapi.yamlis unchanged, deliberately./v1/drivehas zero entries in the generated spec, unlike/v1/folder(8),/v1/browser(4) and/v1/workflow(45) —ContentDriveResourceis excluded from OpenAPI generation entirely. The@Operationtext was updated and is good source documentation, but it does not reach the yaml. Pre-existing gap; flagging rather than working around it, and worth its own ticket.Unrelated polish: Refresh is now role-gated in the UI (#36845)
Not part of the status-filter spec. Carried here rather than held back, so it is in the demo build.
QA on
POST /api/v1/content/_bulkrefreshturned up that a non-admin sees Refresh in Quick Actions,fires it, and gets a 403. That was a deliberate call at the time:
BulkRefreshHelper.canRefreshreads as Power User OR Administrator, and the browser cannot tell whether someone is a Power User,
so gating on
isAdminlooked like it would hide the action from people entitled to it.It does not, because the Power User half never fires. It resolves the role key
"CMS Power User",no role ships with that key,
loadRoleByKeyanswers null, andRoleFactoryImplreturns false for anull role. What survives is
doesUserHaveRole(user, loadCMSAdminRole()), which is character-for-characterUser.isAdmin(), which is what already feedscurrentUserIsAdminin the store. Client and serverevaluate the same expression, so the row can predict the refusal rather than discover it.
Disabled, not hidden — the same treatment Push Publish gets with no environment configured. The row
keeps saying the capability exists; the tooltip says who it is for.
Follows the existing gate shape exactly:
requiresAdminon the action def,missingAdminRolecomputedin
getQuickActions, added to the[disabled]binding, thequickActionHinttooltip and theonSelectQuickActionguard. Three newgetQuickActionstests plus three at component level; fiveexisting Refresh tests now set the admin precondition, since the behaviour they cover starts after the
gate.
Known limit, left alone: an install that hand-created a role keyed
"CMS Power User"would let anon-admin holder through server-side while this row stays shut. dotCMS ships no such role. The clean fix
is to drop the dead branch from
canRefreshso the gate is admin-only by definition, but that is achange to a server-side authorization path and does not belong in a demo-eve commit. Worth its own ticket.
Also touched
Renamed the
status:publishedplaceholder in existingdecodeFilterstests toowner:jane. They usedstatusas a made-up key to exercise unknown-key fallback, and it is now a real key. Changing their expectations to arrays instead would have forced them green while destroying what they test.Checklist
content-drive.status-filter.*keys inLanguage.properties, pluscontent-drive.action-center.requires-adminfor the Refresh gate🤖 Generated with Claude Code