refactor(document): drop the 5-minute Cache-Control TTL on /density (#709) #711
Reference in New Issue
Block a user
Delete Branch "feat/issue-709-remove-density-cache-ttl"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #709.
What
Removes the explicit
Cache-Control: max-age=300, privateheader fromGET /api/documents/density. The density chart is an interactive filter control (click-to-select month, drag-to-select range); a 5-minute browser cache could show stale month counts for up to 5 minutes after a user edits, uploads, transcribes, re-dates, or re-tags a document — actively misleading on a control the user acts on.The in-memory aggregation is sub-200ms p95 over ~5k docs and there is no CDN/proxy cache (DevOps confirmed no load reason to cache at this scale), so the trade-off rightly tilts toward freshness.
Implementation note (deviation from the literal AC)
The issue's AC called for
header().doesNotExist("Cache-Control")("omit, notno-store"). In practice that assertion is impossible: our explicit header was overriding Spring Security's defaultHeaderWriterFilter, which writesCache-Control: no-cache, no-store, max-age=0, must-revalidateon every secured response. Removing our header lets that framework default apply.This over-satisfies the issue's goal — the browser never caches the density response at all (stronger than plain omission), and
no-storeis strictly safer thanprivatefor the per-user filtered data (no caching ⇒ no cross-user leak, addressing Nora's concern for free). Confirmed with the user before proceeding; the test now asserts this real contract.Changes
DocumentController.density()→return ResponseEntity.ok(result)(explicit header removed; unusedCacheControl/TimeUnitimports dropped).DocumentControllerTest:density_emitsPrivateCacheControlHeader→density_isNeverBrowserCached, updated red-first, now asserts the exactno-cache, no-store, max-age=0, must-revalidatecontract.DocumentService.getDensity()Javadoc: dropped the stalemax-age=300 … absorbs repeated browse loadsclaim.No change to
getDensity()computation logic (per out-of-scope). #481 (SQL aggregation) and ETag modeling remain parked.Verification
DocumentControllerTest— 105/105 green (red-first confirmed before the controller change)../mvnw clean package -DskipTests— BUILD SUCCESS.🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
I reviewed this through the clean-code and TDD lens. It is a small, disciplined refactor and it reads correctly.
What I checked
density_emitsPrivateCacheControlHeader→density_isNeverBrowserCached) to describe the new behavior. The rename is the right move — a test name is documentation, and the old name would have lied after this change.CacheControlandjava.util.concurrent.TimeUnitimports were dropped along with the header. Good. Removing the behavior without removing its imports is the most common leftover in a change like this, and it was caught.density()is unchanged in shape;return ResponseEntity.ok(result)is the simplest correct form. No nesting introduced.getDensity()Javadoc now explains the reason for freshness (interactive chart, imperceptible recompute, issue #709) rather than restating a header value. That is exactly the kind of "why" comment I want; the old comment was a stale "what" that contradicted the code.One thing I want to name (not a blocker)
The test now hard-codes the framework default string
"no-cache, no-store, max-age=0, must-revalidate". That is a deliberate, honest assertion of the real contract — I prefer this overdoesNotExist(...)because the AC's version would have been a false assertion (the header is present, written by Spring Security'sHeaderWriterFilter). The deviation from the literal AC is justified and was confirmed with the user.The only fragility: if the Spring Security default directive string ever changes on a framework upgrade, this test breaks. That is acceptable — it breaks loudly with a clear diff, which is precisely when you want to re-confirm the caching contract. I would not weaken it to a
containsString("no-store").Clean change. Tests precede code. Names reveal intent. Ship it.
🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This change is a net security improvement, and I want to give credit for that explicitly.
Security analysis
The old header was
Cache-Control: max-age=300, private. The density response is per-user filtered data (it reflects the caller's search/filter state). Under the old header, that response was cacheable in the user's own browser for 5 minutes.privatecorrectly kept it out of shared caches, so this was never a cross-user leak (CWE-525) on its own — but it was the weaker of the available options.By removing the explicit header, Spring Security's
HeaderWriterFilterdefault takes over:Cache-Control: no-cache, no-store, max-age=0, must-revalidate. That means the response is not written to disk or memory cache at all. For data derived from a user's session/filter context,no-storeis the strictly safer posture — it removes even the same-device, shared-machine residual-cache concern. This over-satisfies the issue's "just omit it" goal in the right direction.On the deviation from the AC
The issue asked for
header().doesNotExist("Cache-Control"). As the author correctly diagnosed, that assertion is impossible here — the framework always writes the header on secured responses. From a security-testing standpoint I strongly prefer the chosen approach: the test now asserts the actual caching contract the browser receives (no-store ...), which is the security-relevant fact. AdoesNotExisttest would have given false confidence that "nothing controls caching" when in factno-storedoes. Codifying the real contract as a permanent regression test is exactly right — if someone later re-adds a weakerprivate, max-age=...header, this test fails.One consistency note (not a blocker, not in scope)
The neighboring
getDocumentThumbnail()endpoint deliberately setsprivate, max-age=31536000, immutablewith a documented threat-model comment (CWE-525, cache-buster in the URL). That is the correct pattern for an immutable, URL-versioned binary. It is fine and unrelated — I mention it only so a future reader doesn't "harmonize" the two endpoints. They have different threat models: the thumbnail is immutable+versioned (cache aggressively), the density JSON is mutable per-filter (don't cache). Both are now correct.No injection, no auth, no data-exposure concerns. Approved.
🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
A small, correct refactor that pushes a caching decision to where it belongs and documents the why. No layering or boundary concerns.
Layer boundaries
The change lives entirely in the controller (HTTP-transport concern) and a Javadoc note in the service. Correct placement: cache headers are a transport detail, not business logic.
density()still delegates aggregation todocumentService.getDensity()— the service contract is untouched, as the issue's "no computation change" constraint requires. No service reaches into another domain. Clean.On the architectural decision and its record
The interesting part of this PR is the discovery that the explicit header was masking Spring Security's default
no-store. That is a genuine architectural fact about how the HTTP layer behaves on secured endpoints, and the PR captures it in both the test (as an executable assertion) and the PR description. I'm satisfied that this is documented where it will be found.I considered whether this warrants an ADR. My judgment: no. This is a localized, reversible config decision on one endpoint, not a lasting structural choice (no new tech, transport, or pattern). The issue #709 thread already records the context, alternatives (omit vs
no-storevs ETag), and consequences. Parking ETag/conditional-requests in #481/#709 rather than building it now is the correct "simplest tool for the actual problem" call — a sub-200ms recompute does not justify conditional-request machinery. Boring, right-sized decision.Documentation currency
I ran the doc-impact checklist. This PR adds no migration, no table/column, no join/FK, no new package, controller, service, route, Docker service, external system,
ErrorCode,Permission, or domain term. The auth-flow and upload-flow sequence diagrams are unaffected (no auth logic changed — only a response header default surfaced). No diagram or doc update is owed. No blocker.Approved.
🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
The change is correct and the test was updated red-first, which I appreciate. My concerns are about test layer and coverage, not about blocking the merge.
What is good
no-cache, no-store, max-age=0, must-revalidate) rather than a loosecontainsString. Exact assertions catch silent widening — if someone re-introducesprivate, max-age=300, this fails. One logical behavior, one assertion. Name reads as a sentence. Good.Concern 1 — this is a
@WebMvcTestslice; verify the security filter is actually in the slice (not a blocker, but verify)The new assertion depends on Spring Security's
HeaderWriterFilterrunning in the test context.DocumentControllerTestmust@Importthe security config (the persona reference shows the canonical@WebMvcTest(DocumentController.class) @Import({SecurityConfig.class, PermissionAspect.class})). If the security config is imported, theno-storedefault is real and the test is valid — the fact that the author confirms 105/105 green with this exact string strongly implies it is. I just want it on record that this test is only meaningful because the security filter is in the slice; if a future refactor dropsSecurityConfigfrom the import, this test would assert a default that no longer applies. A one-line comment in the test pointing at which filter writes the header would harden it. The author did add a comment naming "Spring Security's default no-store directive" — good enough.Concern 2 — the assertion couples to a framework-internal string
This is the right trade-off given the AC was impossible, but I want it logged: the test now fails on a Spring Security upgrade that reorders or rewords the default directive. That is acceptable and arguably desirable (it forces a human to re-confirm the caching contract on upgrade), but it means this is a known "expected to break on framework bump" test. Worth a mental note for whoever does the next Spring Boot upgrade.
Coverage gap (minor, out of scope)
There is no integration-level test (full stack, real Postgres via Testcontainers) proving an actual edit is immediately reflected in
/density— i.e. the user-facing behavior the issue cares about ("no 5-minute staleness after an edit"). The MockMvc test proves the header contract; it does not prove freshness end-to-end. That end-to-end behavior is now guaranteed byno-store+ no server cache, so I am not asking for it here, but if the density chart ever grows a server-side cache (#481), that integration test becomes mandatory.No flaky-test, no H2, no
Thread.sleepconcerns. Approved with the notes above.🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
I was cited in the issue as confirming there is no load reason to cache this at current scale, and nothing in the diff changes that assessment.
Operational impact
/api/documents/density. So the oldprivate, max-age=300header only ever affected individual browsers — removing it has zero impact on origin load from shared caches (there were none).Nothing to flag from the platform side. Approved.
📋 Elicit — Requirements Engineer & Business Analyst
Verdict: ⚠️ Approved with concerns
Working in Brownfield mode. I assessed this against the issue's acceptance criteria and the underlying user need. The change is sound and well-justified; my concern is purely about AC traceability hygiene, which matters for a solo, issue-driven, LLM-assisted workflow.
Requirement satisfied — root need is met
The real requirement (problem language, not solution language): "While a user is acting on the interactive density chart, the system shall show month counts that reflect the user's most recent edits, without a multi-minute staleness window." This maps to Nielsen heuristic #1 (visibility of system status), correctly flagged in the issue. The chosen implementation (
no-storevia framework default) satisfies this even more strongly than plain header omission would have — staleness window is now zero. ✅ The benefit is delivered.Concern — one written AC was not met as literally specified
The issue lists, as a checkbox AC:
The PR does not do this — it asserts the exact
no-storestring instead. The PR body explains, credibly, thatdoesNotExistis technically impossible because Spring Security always writes the header. I agree with the engineering decision. But from a requirements-discipline standpoint this is an unmet written AC, and the correct close-out is not to silently override it — it is to amend the issue body so the AC matches reality before merge. Right now the issue and the PR disagree on what "done" means. For a backlog that aims to be spec-dense and the source of truth, that drift is the thing to fix.Recommendation (lightweight): edit issue #709's acceptance criteria to replace the
doesNotExistline with: "assert the response carries the framework-defaultno-cache, no-store, max-age=0, must-revalidate(the explicit header was masking this default; omission yields a stronger no-cache contract than the AC originally assumed)." Then the merge closes a fully-satisfied issue. This is a 30-second edit, not a code change.Ambiguity I want to name
The issue's own "Decision" section says "'Remove' = omit the header, not
no-store" and even arguesno-store"adds no real protection here." The PR delivers exactlyno-store. So the delivered outcome contradicts a sentence in the agreed decision. The PR's reasoning (the default was alreadyno-store, omission can't undo it) resolves the contradiction correctly — but again, this is precisely the kind of decision-vs-reality drift that should be folded back into the issue so the next reader isn't confused. The author notes it was "confirmed with the user," which is good; capture that confirmation in the issue body.Other AC items — header removal (✅), Javadoc updated (✅), no computation change (✅), out-of-scope items parked (✅) — are all met. No NFR gaps: performance is unaffected (sub-200ms), no a11y/security/observability implications.
Approved, conditional on tidying the issue's ACs to match what shipped. Not a code blocker.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This is a backend-only change with no markup, styling, or component diff — so there is nothing for me to check on contrast, touch targets, focus rings, semantic HTML, or responsive breakpoints. But it is not UX-neutral, and I want to register why it is a positive for users.
Why this matters for the user
The density chart is an interactive filter control — users click a month or drag a range to filter. Under the old 5-minute browser cache, a user who uploaded, re-dated, transcribed, or re-tagged a document could click that chart and act on stale month counts for up to five minutes, with no indication anything was out of date. That is a direct violation of visibility of system status (Nielsen #1): the interface would have been confidently wrong on a control the user manipulates. Removing the cache means the chart always reflects the current archive state. The control is now honest about what it represents.
This is exactly the "design for the hardest constraint first" instinct applied to data freshness: the 60+ researcher who just corrected a document's date should see that correction reflected immediately, not wonder why the timeline still shows the old bucket.
No accessibility regression
No visual state, no animation, no color, no timing affordance changed.
prefers-reduced-motion, contrast, and keyboard reachability are untouched. The freshness improvement happens transparently — the user is not asked to refresh or dismiss anything.Nothing to fix. Approved from the UX/accessibility side.