Skip to content

docs(content-drive): spec and design for Status filter (Archived, Unpublished, Locked) (#37066) - #37170

Open
zJaaal wants to merge 10 commits into
mainfrom
issue-37066-content-drive-status-filter
Open

docs(content-drive): spec and design for Status filter (Archived, Unpublished, Locked) (#37066)#37170
zJaaal wants to merge 10 commits into
mainfrom
issue-37066-content-drive-status-filter

Conversation

@zJaaal

@zJaaal zJaaal commented Aug 24, 2026

Copy link
Copy Markdown
Member

Spec-Kit PR 1 of 2. Carries the spec and the two design artifacts that clear the commit-policy bar in .specify/CUSTOMIZATIONS.md. Needs a developer approval (not a merge) — the implementation PR stacks on this branch rather than waiting for it to land on main.

Resolves the spec + plan phases of #37066.

Proposed Changes

  • spec.md — the reviewed contract: 5 prioritized user stories, 20 functional requirements, 7 success criteria, edge cases, and the dotCMS Legacy Considerations section.
  • data-model.md — the ContentStatus enum and its column/index mapping, the status transport shape and validation rules, the per-selection predicate table, the BrowserQuery field, the frontend filter-bag entry.
  • contracts/drive-search-status.md — the status field on POST /api/v1/drive/search.

plan.md, research.md and quickstart.md were generated and stay local, per the commit policy in #36416. The two committed design docs are written to stand on their own — no citations point at files outside this diff.

⚠️ Semantics reversed since the ticket was written

The ticket originally specified these statuses combine with AND. This spec says OR — selecting more statuses returns more content, exactly like the Content Type and Language filters beside it. #37066's body and acceptance criteria have been updated to match, with a comment recording why, so the ticket and the spec do not diverge.

The case for AND was that these are independent flags one item can hold at once, so intersecting them is meaningful. It is — for one of four combinations:

Combination Under AND Under OR
Archived + Unpublished redundant — identical to Archived alone, since archiving removes the live version "everything with no live version"
Archived + Locked almost always empty "archived or checked out"
Unpublished + Locked useful — "drafts checked out" "drafts or checked out"
all three empty in practice "anything not cleanly published"

The first draft of this spec had to document two of those as "expected, not a bug" — that disclaimer was the design objecting. The decisive point is consistency: every other chip in that toolbar row widens on selection, and nothing in the UI can convey that one of them inverts the rule. A user who checks a second box and gets fewer results reads that as broken. The other filters being OR is forced by their single-valued nature (an item has exactly one base type), but that reasoning is invisible to the user; all they learn is the pattern.

The thing to sign off on deliberately: "unpublished and locked" — drafts currently checked out — is no longer expressible anywhere in Content Drive. Called out in spec.md Assumptions rather than absorbed quietly. If it turns out to matter, it belongs in a later refinement that makes the combining rule explicit in the UI.

Design decisions worth a reviewer's eye

1. All three predicates resolve in the database. ADR-0018 routes version-info flags (archived/deleted) to the DB and says structural criteria must never be re-routed to the index for speed. All three statuses are version-info flags:

Status SQL disjunct Index term
ARCHIVED cvi.deleted = true deleted:true
UNPUBLISHED cvi.live_inode is null live:false
LOCKED cvi.locked_by is not null locked:true

The index terms exist only because BROWSE_API_HEURISTIC_TYPE=PURE_ES is a supported (non-default) configuration where the filter would otherwise silently no-op, returning a wider set than asked for. Under the default hybrid heuristic the SQL clauses apply with and without a keyword.

2. The archived baseline sits OUTSIDE the OR group. Excluding archived content is the drive's pre-existing default, not a fourth status. The selected statuses are OR-ed together and that group is AND-ed against the baseline, which only ARCHIVED lifts:

  and cvi.deleted = false                                        -- baseline, unless ARCHIVED selected
  and ( cvi.live_inode is null or cvi.locked_by is not null )    -- the selected statuses

Folding the baseline into the group is the bug to avoid — [UNPUBLISHED, LOCKED] would become (deleted = false or …) and match nearly every row: a filter that silently stops filtering.

3. status is typed List<String>, not List<ContentStatus>. FR-010 wants a 400 on an invalid value "consistent with how userSearchable rejects unknown keys", and that precedent is an explicit BadRequestException in ContentDriveHelper. A typed field would let the Immutables/Jackson layer reject it as an InvalidFormatException whose mapping to a useful 400 is not under this code's control. The typed field would read better; the deterministic error is worth more.

4. No schema or mapping change. deleted, live_inode and locked_by already exist on contentlet_version_info (postgres.sql:550-552); locked is already indexed (ESMappingAPIImpl.java:527). Nothing here is rollback-unsafe.

5. The existing showArchived is left alone. It is inclusive — archived content plus everything else — and the legacy Site Browser checkbox (view_browser.jsp:145) depends on that. The exclusive Archived behavior is added alongside (FR-008), with an integration assertion guarding the old one.

The risk this design carries into PR 2

Two existing code paths already have an opinion about cvi.deleted, and both must learn about ARCHIVED or the filter breaks:

  • The global archived exclusion (BrowserAPIImpl:2006) would otherwise emit cvi.deleted = false AND-ed against a group containing = true, so ARCHIVED would always return nothing.
  • Archive-target workflow steps (:1980) — appendWorkflowQuery owns cvi.deleted per branch when such a step is selected, so ARCHIVED needs the same treatment showArchived already gets. ContentDriveWorkflowArchiveStepTest is the regression guard.

A third, quieter one: BrowserQuery's showWorking derivation (:151) must also be true for ARCHIVED/UNPUBLISHED, or selectQuery joins on live_inode — which those rows never have — and the filter returns nothing with no error. See data-model.md.

With no status sent, every generated query stays byte-identical to today.

UI placement and navigation

The chip sits between the shared-assets and content-type filters, so the row reads broadest-scope-first: which assets are in play → which state of them → what kind → where in the workflow → which locale. The selection rides the shared filters bag rather than its own query param, so it inherits deep link, reload, folder browsing, Back/Forward and the legacy-editor round-trip identically to every other filter (FR-016).

Checklist

  • Tests — land in PR 2, written and confirmed failing first (Constitution Principle V). Strategy: unit for status parsing + the 400; integration for each status, each pair, all three, the empty default, the never-shrinks property, PURE_ES parity and the workflow archive-step regression; Jest/Spectator for the chip, the store payload and the URL round-trip (including the single-value decode case, where a lone status:ARCHIVED would otherwise decode as a string rather than an array)
  • Translations — four content-drive.status-filter.* keys, in PR 2
  • Security Implications Contemplated — the only input is a closed enum, validated and rejected with a 400, so it never reaches SQL as text. Permission filtering is untouched, so the filter grants no new visibility

Additional Info

Absorbs #37067 (closed, merged into #37066). Parent epic #33999. Shared Assets / System Host toggle out of scope (#34760). #37171 was an earlier split of the design into its own stack layer, folded back here.

/speckit-adr-context ran as the mandatory before_plan hook. ADR-0018 (proposed) governs and this design complies; ADR-0009 (accepted) constrains only the index terms, all core fields mapped identically under ES and OpenSearch; ADR-0020 surfaced but does not apply. No new ADR proposed.

🤖 Generated with Claude Code

This PR fixes: #37066

zJaaal and others added 2 commits August 24, 2026 10:51
Spec-Kit PR 1 for #37066: the Archived / Unpublished / Locked filter on
drive search and in the Content Drive toolbar.

Carries spec.md alone, per the two-PR spec-driven flow. The plan artifacts
land in the next PR of the stack, based on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37066)

Phase 1 design artifacts for the Status filter, stacked on the spec PR.

Carries only the two artifacts .specify/CUSTOMIZATIONS.md considers durable:
data-model.md (the ContentStatus enum, the `status` transport shape, the
filter-bag entry) and contracts/ (the `status` field on POST /api/v1/drive/search,
including the AND semantics and the 400 on an unknown value).

plan.md, research.md and quickstart.md stay local by design — the repo
gitignores them as process-only artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zJaaal's task in 31s —— View job


Claude finished @zJaaal's task in 2m 3s —— View job


SDK Compatibility Analysis — Complete ✅

  • Read docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md
  • Get full PR diff (origin/main...HEAD)
  • Analyze diff against each breaking-change category
  • Post verdict

Verdict: No SDK breaking change.

This PR is documentation-only — the entire diff is three new Markdown files under specs/37066-content-drive-status-filter/:

specs/37066-content-drive-status-filter/contracts/drive-search-status.md | 135 ++++
specs/37066-content-drive-status-filter/data-model.md                   | 232 ++++
specs/37066-content-drive-status-filter/spec.md                         | 302 ++++
3 files changed, 669 insertions(+)

There is no change to any of the surfaces the breaking-change categories cover:

  • GraphQL (G-1/G-2/G-3): no changes to page-api.ts, GraphQL schema, or query builders.
  • REST response shapes (R-1): no changes to /api/v1/page/*, /api/v1/content, /api/v1/nav, or any Java REST resource/DTO. contracts/drive-search-status.md documents a future status field for POST /api/v1/drive/search (a new, optional request field), but no server code implementing it lands in this PR — this is Spec-Kit PR 1 of 2 (spec + design only); the implementation is deferred to PR 2 per the PR description.
  • UVE/editor postMessage protocol (U-1/U-2): untouched.
  • SDK compatibility headers / compareVersions() (H-1): untouched.

No comment or label added, consistent with the "no action when not breaking" instruction.

zJaaal and others added 2 commits August 24, 2026 11:07
Restores stock Spec-Kit behavior for the three artifacts a reviewer needs to
judge a design. Only tasks.md and checklists/ stay gitignored.

The original policy in #36416 kept all five local on the "process artifact"
test. That predates this repo adopting GitHub's native stacked pull requests
(public preview 2026-07-30), which changes the calculation: the spec-driven
flow now lands as a stack whose middle layer exists purely to review the
design. Gitignoring plan.md makes that layer a PR with nothing to review.

tasks.md and checklists/ stay ignored — both are regenerated by their own
command, and a merged task list that no longer matches what was built is worse
than no task list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37066)

Now trackable after the preceding gitignore change. These are the artifacts
/speckit-plan already generated for this feature; they were sitting on disk,
invisible to review.

- plan.md      design, Legacy Impact, Constitution Check, ADR Alignment gate
- research.md  R1-R10, each verified against the code rather than the issue text
- quickstart.md build/test commands and the end-to-end validation walkthrough

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal zJaaal changed the title docs(content-drive): spec for Status filter (Archived, Unpublished, Locked) (#37066) docs(content-drive): spec and design for Status filter (Archived, Unpublished, Locked) (#37066) Aug 24, 2026
zJaaal and others added 2 commits August 24, 2026 11:12
…7066)

Two non-blocking notes from the automated review on #37170, both verified
against the code before acting:

1. FR-004 vs FR-006 read as a contradiction to a strict "pure AND of three
   independent flags" implementer. They are not: excluding archived content is
   the drive's pre-existing baseline (appendExcludeArchivedQuery runs on every
   request today), the statuses AND on top of it, and ARCHIVED is the only one
   that lifts it. data-model.md now spells this out with a per-selection
   predicate table, and contracts/ points at it.

2. LOCKED and version scoping compose, as the reviewer suspected. Confirmed in
   the legacy portlet: ContentletAjax.java:1018 appends +locked:true and :1035
   unconditionally appends +working:true. Documented, including the one
   deliberate difference — legacy always scopes to working, whereas here only
   ARCHIVED and UNPUBLISHED force it, so live:true + LOCKED is a coherent
   "live content that is locked" query rather than a bug.

Adds the two integration cases the review asked for, including the fixture note
that the FR-004 assertion needs an item that is both archived and unpublished or
it passes vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I added this to the spec PR by mistake. It is not a spec artifact: it is a
per-developer pointer to the feature you are currently on, rewritten by
/speckit-specify and read by get_feature_paths() so the downstream commands can
locate the spec folder without depending on branch naming.

Committing it would aim every teammate's next /speckit-plan at whatever feature
last merged, and two concurrent feature branches would conflict on its single
line every time. It has never been committed on main — the 36605 and 36834
features both shipped without it — but it was not gitignored either, so it was
untracked by luck rather than by rule. Now it is a rule.

Callers who want to target a feature explicitly should set
SPECIFY_FEATURE_DIRECTORY, which takes precedence over the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal

zJaaal commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Both 🟡 notes addressed in b154169, and a third issue the review surfaced indirectly in fe40cd34. Note the PR has grown since that review: it now carries the design artifacts too (plan.md, research.md, quickstart.md, data-model.md, contracts/), which were briefly split into #37171 before being folded back here.

1. FR-004 vs FR-006 — right that a strict reader would get this wrong. They're not contradictory, but the spec never said why. Excluding archived content is the drive's pre-existing baseline (appendExcludeArchivedQuery runs on every request today), the statuses AND on top of it, and ARCHIVED is the only one that lifts it. data-model.md now has a per-selection predicate table making that explicit, and contracts/ points at it. Worth noting it needs no special handling in code — the baseline is skipped only when the selection contains ARCHIVED, so UNPUBLISHED/LOCKED alone keep it for free.

2. LOCKED + working:true — verified, composes cleanly. ContentletAjax.java:1018 appends +locked:true and :1035 unconditionally appends +working:true, so legacy has always paired them. Documented, along with one deliberate difference: legacy always scopes to working, whereas here only ARCHIVED and UNPUBLISHED force it (neither can have a live version). So live: true + LOCKED yields "live content that is locked" — coherent and strictly more expressive, not a bug.

Both now have integration cases in the plan's Test Strategy. The FR-004 one carries a fixture note: it needs an item that is both archived and unpublished, or the assertion passes vacuously.

3. On feature.json — the review passed it as "no issue", but it was wrong to commit and it's now removed. It isn't a spec artifact; it's a per-developer pointer to the feature you're currently on, rewritten by /speckit-specify. Committed, it would aim every teammate's next /speckit-plan at whatever feature last merged, and two concurrent feature branches would conflict on its single line every time. It had never been committed on main (36605 and 36834 both shipped without it) but wasn't gitignored either — untracked by luck. Now it's a rule, documented in .specify/CUSTOMIZATIONS.md.

Also confirmed the buildPureESQuery:612 point: it's research.md R6 and has its own PURE_ES parity test in the strategy.

Analysis and reply by Claude (Claude Code), posted from @zJaaal's account.

zJaaal and others added 2 commits August 24, 2026 11:26
Reverses the ticket's original AND semantics after review.

The case for AND was that these are independent flags one item can hold at
once, so intersecting them is meaningful. It is — for one of four 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 is empty in practice. Only UNPUBLISHED+LOCKED
says anything. Under OR all four are meaningful.

The decisive point is consistency: every other chip in that toolbar row widens
on selection, and no UI affordance can convey that one of them inverts the rule.
A user who checks a second box and gets fewer results reads that as a bug. The
other filters' OR-ness being forced by their single-valued nature is invisible
to the user; all they learn is the pattern.

Accepted cost, recorded in Assumptions: "unpublished AND locked" is no longer
expressible in Content Drive.

Implementation shape changes from N independent `and` clauses to one OR-ed group
AND-ed against the archived baseline, which stays OUTSIDE the group — folding it
in would make [UNPUBLISHED, LOCKED] read `(deleted = false or ...)` and match
nearly every row.

Also settles the toolbar position (between the shared-assets and content-type
filters, so the row reads broadest-scope-first) and makes the navigation
requirement explicit: status persists across deep link, reload, folder browsing,
Back/Forward and an editor round-trip, exactly like every other filter, because
it rides the shared filters bag rather than its own query param.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the commit policy from #36416: plan.md, research.md and quickstart.md
are process artifacts and stay local. They remain on disk, so /speckit-implement
and /speckit-converge are unaffected.

I proposed un-ignoring them because a stacked design PR with a gitignored
plan.md has nothing in it to review. That was solved differently instead — the
spec and its design artifacts are now one PR, so there is no design layer
needing its own diff.

Keeps two things from the reverted change:
- .specify/feature.json stays gitignored. It is a per-developer pointer, not a
  spec artifact, and had never been committed on main — untracked by luck rather
  than by rule.
- data-model.md no longer links to the now-local research.md; each reference is
  summarized inline so the committed document stands on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal
zJaaal force-pushed the issue-37066-content-drive-status-filter branch from ea2d38c to c829fec Compare August 24, 2026 14:27
Review note on #37170: the design rationale cited research.md items (R2, R4,
R7), but research.md is gitignored by the commit policy this PR reaffirms, so a
reviewer could not verify the load-bearing claims. The R-numbers read as
citations to something unreviewable.

Fixed both ways the review suggested: the reasoning is now inlined in full and
the R-references are gone, so nothing in the committed docs points outside the
diff.

The showWorking passage gained the most: it now names the two call sites that
make the flag load-bearing (BrowserAPIImpl:1947 picking the joined inode column,
:615 choosing +working:true vs +live:true), says why the join can never match for
archived/unpublished rows, and notes that the drive path is safe today only by
coincidence of live() defaulting to false — plus that LOCKED alone does not need
the flag, since a locked item may have a live version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal

zJaaal commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Thanks — one real finding, and two that were made against a stale snapshot of the PR description.

🟡 R-references pointing at a gitignored file — valid, fixed in a74385f. Citing research.md R2/R4/R7 from a committed document, in a PR that reaffirms gitignoring research.md, is exactly the tension you describe. Did both things you suggested: the reasoning is inlined in full and the R-numbers are gone, so nothing in the committed docs points outside the diff.

The showWorking passage gained the most, since it was the least verifiable. It now names the two call sites that make the flag load-bearing — BrowserAPIImpl:1947 picking the joined inode column, and :615 choosing +working:true vs +live:true — explains why the join can never match for archived/unpublished rows, and adds two things that were only in research.md before: the drive path is safe today only because the form's live() defaults to false (a coincidence in one caller, not a property of BrowserQuery), and LOCKED alone does not need the flag, since a locked item may well have a live version. Also dropped the last dangling pointer from the PR body's test-strategy line.

🟡 "PR description vs committed spec — stale AND/OR narrative" — already fixed before the review ran. The body was rewritten at 14:32 UTC and no longer contains the "The one thing to check in the spec" section you quote. It now leads with ⚠️ Semantics reversed since the ticket was written, the same four-combination comparison table, and the explicit trade-off callout. I think the review read an earlier revision — worth knowing the description can move under a review that takes a couple of minutes.

To answer the substantive half anyway, since it deserves a direct answer: yes, OR is deliberate, and yes it flips the ticket's original requirement. That AND wording was my own earlier rewrite of #37066, not a team decision, and it did not survive planning — under AND, two of three pairs are degenerate and the first draft of the spec had to document them as "expected, not a bug," which was the design objecting. The decisive point is that every other chip in that toolbar row widens on selection and nothing in the UI can convey that one of them inverts the rule. #37066's body and acceptance criteria are now updated to match, with a comment recording why, so the ticket and the spec no longer diverge. The Unpublished AND Locked trade-off is called out in spec.md Assumptions precisely so it gets signed off deliberately rather than absorbed.

Minor — "PR body still lists plan.md/research.md/quickstart.md under Proposed Changes" — same stale snapshot. Proposed Changes lists only the four committed items; the three process artifacts appear in one line stating they stay local per #36416.

Analysis and reply by Claude (Claude Code), posted from @zJaaal's account.

Comply with the existing policy rather than amending it. This PR now touches no
policy files at all — .gitignore and .specify/CUSTOMIZATIONS.md are identical to
main.

feature.json stays out of the commit (it was never tracked on main, and 36605
and 36834 both shipped without it). It is simply untracked rather than ignored,
which is exactly main's state today. If the team wants it ignored, that belongs
in its own PR against the policy, not carried along inside a feature branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Content Drive: Status filter (Archived, Unpublished, Locked) on drive search and in the toolbar

1 participant