Skip to content

spec: Feature-flag gates for analytics and experiment endpoints (#37659) - #37690

Open
freddyDOTCMS wants to merge 35 commits into
mainfrom
issue-37659-analytics-experiment-flag-gates
Open

freddyDOTCMS wants to merge 35 commits into
mainfrom
issue-37659-analytics-experiment-flag-gates

Conversation

@freddyDOTCMS

@freddyDOTCMS freddyDOTCMS commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements feature-flag and Analytics App configuration gates for experiment activation and analytics proxy endpoints (issue #37659).


Behavior Matrix

FEATURE_FLAG_EXPERIMENTS states

flag=true (FULL) flag=false (LIMITED, slot free) flag=false (LIMITED, slot used)
POST /_start (immediate) Allowed (if App configured) Allowed — max 10 days, auto-set if no end date 403 FEATURE_DISABLED
POST /_start (future-dated) Allowed (if App configured) 403 FEATURE_DISABLED 403 FEATURE_DISABLED
POST /scheduled/_cancel Allowed 403 FEATURE_DISABLED 403 FEATURE_DISABLED
PUT /_archive Allowed 403 FEATURE_DISABLED 403 FEATURE_DISABLED
POST /_end Allowed 403 FEATURE_DISABLED 403 FEATURE_DISABLED
GET /health 200 always 200 always 200 always
CRUD, isUserIncluded Allowed Allowed Allowed
GET /{id}/results Allowed (if App configured) Allowed Allowed

Analytics App configuration states (any flag state)

App configured App not configured
POST /_start See flag matrix above 503 ANALYTICS_NOT_CONFIGURED
POST /_cancel, PUT /_archive See flag matrix Not gated (no CAEM interaction)
POST /_end Not gated (flag-only) Not gated (no CAEM interaction)
GET /{id}/results Allowed 503 ANALYTICS_NOT_CONFIGURED
GET /experiments/health Returns tier, health, freeExperimentUsed, warning Same — warning: "analytics_disabled" added
GET /analytics/health 200 { health: "ok" } 200 { health: "not_configured" } — never 503
GET /analytics/{path} (events, etc.) Proxied to CAEM 503 ANALYTICS_NOT_CONFIGURED
POST /analytics/content/event Forwarded (App check before site_auth) 503 ANALYTICS_NOT_CONFIGURED
GET /analytics/content/siteauth/generate/{id} 200 always 200 always — never gated

GET /experiments/health response shape (new)

flag App tier freeExperimentUsed warning
true configured "full" absent absent
true not configured "full" absent "analytics_disabled"
false configured "limited" true/false absent
false not configured "limited" true/false "analytics_disabled"

Key Implementation Details

  • FEATURE_FLAG_EXPERIMENTS default flips true → false — new installs start in limited mode
  • Live-toggle removed — ConfigExperimentUtil.notify() no longer refreshes the flag; a restart is required for flag changes
  • ExperimentLimitedModeGate — public gate class that owns the 10-day cap (MAX_DAYS = 10L) and defaultEndDate() used by both the resource gate check and ExperimentsAPIImpl.startLimitedScheduling()
  • ExperimentsHealthView — new response type with Tier ("full"/"limited") and Warning ("analytics_disabled") enums serialized lowercase via @JsonProperty
  • GET /analytics/health — changed from raw CAEM proxy to dotCMS-evaluated 3-state response (OK/NOT_CONFIGURED/CONFIGURATION_ERROR)
  • Gate ordering on POST /analytics/content/event — App-config check fires before site_auth validation (inverted from the previous order)
  • isFreeSlotUsed() — queries {RUNNING, SCHEDULED, ENDED} experiments globally; no in-memory cache (DB is cluster-shared authority)
  • openapi.yaml — regenerated with @JsonProperty enum values and @ApiResponse annotations on gated endpoints

Test Coverage

  • 42 unit tests — ConfigExperimentUtilTest, ExperimentsAPIImplTest, ContentAnalyticsUtilTest, ExperimentsResourceTest, EventAnalyticsProxyResourceTest — all green
  • Integration tests — EventAnalyticsProxyResourceIntegrationTest (real AppsAPI round-trip for analyticsHealth), ExperimentsResourceIntegrationTest (live-toggle removal verified); both registered in MainSuite2a
  • Postman — Experiment_Gate collection (27 assertions: flag gate 403s, health endpoint fields, analytics 503/200 behavior) — passed against live server

Breaking Change

FEATURE_FLAG_EXPERIMENTS default flips from true to false.
Any customer not explicitly setting this flag will have experiments silently disabled after this release.

Pre-ship audit status — COMPLETE: Active customer inventory has been reviewed and confirmed — no customers are currently using the Experiments feature in production. The flag default flip is safe to ship without setting FEATURE_FLAG_EXPERIMENTS=true on any customer instance. If that changes before this PR merges, set the flag explicitly for affected customers before deploying.

Note for reviewers: FR-010 in the spec required auditing integration tests for implicit reliance on the old default of true. That audit is complete (T009/T010 in tasks.md) — two tests in ConfigExperimentUtilTest that previously relied on the default were updated to call setExperimentEnabled(true) explicitly in their setup.


FE Changes

Angular / TypeScript changes are in companion PR #37957 (same feature branch, based on this one). Both PRs must merge together.

🤖 Generated with Claude Code

ref: #29555
Changing link from `/c/dotAI` to `/c/dotai`
@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @freddyDOTCMS's task in 2m 29s —— View job


Claude PR Review

Reviewed the backend gating changes (ExperimentsResource, ExperimentLimitedModeGate, ExperimentsAPIImpl, ContentAnalyticsUtil, EventAnalyticsProxyResource, ConfigExperimentUtil). Only flagging issues introduced by this PR.

New Issues

  • 🟡 Medium: ExperimentLimitedModeGate.java:80-84 (with ExperimentsAPIImpl.java:591-606) — the 10-day limited-mode cap is bypassable. The duration check only fires when an endDate is present (endDateOpt.isPresent() && ... > MAX_DAYS), and the auto-set of a default end date only happens in startLimitedScheduling(), which is only reached when emptyScheduling(...) is true. emptyScheduling returns true only when scheduling is absent or both startDate and endDate are empty (ExperimentsAPIImpl.java:744-748). So a limited-mode start with a scheduling object carrying an immediate (non-future) startDate and no endDate passes the gate (not future → no 403; endDate absent → no 400) and then takes the else branch at ExperimentsAPIImpl.java:599, persisting a SCHEDULED experiment with no end date and no cap applied — defeating the single-free-time-boxed-experiment intent the PR is built around.
    Assumption: a client can submit scheduling with only a startDate. What to verify: does the Experiments UI / API ever send a startDate without an endDate? If so, enforce the cap (or auto-set the default end date) in the else branch too, not only in startLimitedScheduling(). There is no test covering this input in ExperimentsResourceTest/ExperimentsAPIImplTest. Fix this →

  • 🟡 Medium: EventAnalyticsProxyResource.java:160-172 — on the public POST /content/event endpoint, inverting the gate so getSiteFromRequest() + isAppConfigured() run before any site_auth validation means an unauthenticated caller can now drive site resolution (from attacker-controlled Origin/Referer) and an AppsAPI secret read as systemUser, and can distinguish configured vs. unconfigured sites by the 503 ANALYTICS_NOT_CONFIGURED vs 400 response. This is a minor information-disclosure / unauthenticated-work surface.
    Assumption: /content/event has no backend-user requirement (confirmed — auth is via site_auth only). What to verify: this ordering is intended per FR-006; if so, acceptable, but confirm the 503-vs-400 distinction to anonymous callers is acceptable for the threat model.

Notes (non-blocking)

  • EventAnalyticsProxyResource.java:167 and :232 read getAppSecrets(site) twice per ingest request (isAppConfigured then the persistenceMode lookup). Minor extra round-trip; consider fetching the secrets map once and passing it down.
  • The GET /v1/experiments/health response-shape change (Map<String,Health> → ExperimentsHealthView) and the start/_archive/_end/_cancel return-type change to bare Response are intentional but are a backward-incompatible API-contract change (already called out by the rollback bot). Not a bug — flagging only so it's a conscious decision, since the companion FE PR feat(37659): frontend limited-mode UI and analytics health gate (FE) #37957 depends on the new fields.

Everything else — the isFreeSlotUsed() SQL COUNT guard against full-mode misuse (ExperimentsAPIImpl.java:521-526), the freeExperimentUsed null-guard in healthcheck() (ExperimentsResource.java:669), Config/Logger usage, addParam-based SQL, and the SecretsStoreUnreadableException degrade path — looks correct.

· branch issue-37659-analytics-experiment-flag-gates

@freddyDOTCMS
freddyDOTCMS marked this pull request as draft September 22, 2026 19:30
@freddyDOTCMS
freddyDOTCMS marked this pull request as ready for review September 22, 2026 19:30
freddyDOTCMS and others added 3 commits September 22, 2026 17:40
…ts (#37659)

Defines gate behavior for FEATURE_FLAG_CONTENT_ANALYTICS and
FEATURE_FLAG_EXPERIMENTS across EventAnalyticsProxyResource and
ExperimentsResource, including event filtering, context stripping,
siteauth access rules, and restart-required flag enforcement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ges, auth ordering

- Expand Independent Test in all 4 user stories: add unit tests for gate
  logic and payload manipulation; add Postman/integration split with
  explicit rationale for why non-403 scenarios cannot use Postman
- Add Contract Changes section declaring new 403 response paths on all
  affected endpoints and the context.experiments payload mutation
- Add auth-ordering rule to FR-001 (auth before gate; 401 not 403) with
  explicit exception for POST event endpoint (site_auth returns 400)
- FR-003a: clarify /health is an existing endpoint, not a new one
- FR-010: add Postman test declaration for error envelope shape
- FR-011: replace startup-cache language with outcome statement; add
  rollback-safety note; add integration test declaration
- FR-011: change implementation-prescriptive scope to observable outcome
- FR-001: add auth-ordering exception for POST /analytics/content/event
- Add UI health-check note to FR-002a and FR-003a

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@freddyDOTCMS
freddyDOTCMS force-pushed the issue-37659-analytics-experiment-flag-gates branch from 07760e6 to d81e076 Compare September 22, 2026 23:44
…, flag/gate contradiction

- FR-007: clarify context.experiments stripping is top-level only (shared
  across all events in the batch, not per-event)
- FR-009: define whole-batch-reject semantics — if any event has a
  non-pageview event_type the entire request is rejected with 403;
  rationale: gate is atomic, silent partial drops obscure client bugs
- Key Entities: fix internal contradiction — describe general live-toggle
  behavior and explicitly carve out the gate as startup-read-only exception
- FR-003: drop "and health" exception — FR-003a is the single source for
  the /health path; FR-003 now covers all paths except siteauth
- FR-003a: clarify that /analytics/health changes from pass-through proxy
  to a dotCMS-produced three-state response (OK/NOT_CONFIGURED/
  CONFIGURATION_ERROR); call out as intentional scope beyond "add a gate"
- Event Payload entity: correct shape to {"context":{...},"events":[...]}
  and note which gate logic operates per-event vs. top-level

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cs, UI messages

- Remove in-flight edge case (moot with startup-only gate reads)
- FR-002a: clarify three-state health check already exists; only the 403
  gate is new scope for this feature
- FR-003a: document Analytics portlet frontend changes required — update
  health check client to consume OK/NOT_CONFIGURED/CONFIGURATION_ERROR;
  specify NOT_CONFIGURED and CONFIGURATION_ERROR analytics-specific messages
- Legacy Considerations: add accepted-exception note for GET
  /analytics/health response shape change when flag=ON; defer to FR-003a
  for rationale
- FR-011: state current live-toggle behavior explicitly, then declare gate
  MUST NOT use it; name divergent state as intentional; call wiring gate
  into live-refresh path an implementation error; fix MUST→MUST NOT
- FR-003: drop stale "and health" exception (FR-003a is single source)
- Key Entities: fix internal contradiction — gate reads startup-only,
  live-toggle preserved for all other consumers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
freddyDOTCMS and others added 5 commits September 23, 2026 12:29
…h model

- Gate ordering: pin feature-flag gate before persistenceMode=readonly
  check on POST /api/v1/analytics/content/event; 403 short-circuits the
  chain before persistenceMode is evaluated
- FR-010: require FEATURE_DISABLED error code on all flag-disabled 403s
  to distinguish from SITE_ACCESS_DENIED on the same endpoints; add
  concrete response example and update Postman test assertion
- FR-003a: preserve catch-all auth model — backend user required (401),
  site READ permission required (403 SITE_ACCESS_DENIED); both run before
  the feature-flag gate and before the health evaluation
- FR-002a: clarify existing health check logic is unchanged; only the
  403 gate is new scope for this feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clarify that the true→false default change applies to every consumer of
both flags across the system, not only the new gate — ensures a new
deployment without explicit config is fully disabled with no internal
consumers active. Explain why gate-only would create an inconsistent
state. Remove implementation-specific class names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes backward-compat scope question from review: no consumers other
than the Analytics portlet are known to read GET /api/v1/analytics/health,
so the FR-003a response-shape change affects only the portlet, which is
updated as part of this feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jcastro-dotcms
jcastro-dotcms previously approved these changes Sep 24, 2026
…o and Arcadio feedback

Replace all-or-nothing 403 batch rejection with a partial-success response
model that mirrors CAEM's own contract:

- POST /content/event never returns 403 for gate reasons; instead returns
  202 (success > 0) or 400 (success == 0) with per-event outcomes
- New response fields: discarded count, discardedEvents array (gate-filtered
  events with code: "FEATURE_DISABLED"), re-mapped CAEM error indexes
- FR-006: both flags OFF → 400 ERROR, all events in discardedEvents
- FR-007: analytics=ON experiments=OFF → strip context.experiments, forward
  all events; no discardedEvents (stripping is a context mutation)
- FR-008: both ON → pass CAEM response through as-is
- FR-009: analytics=OFF experiments=ON → per-event filtering; pageview
  forwarded, non-pageview discarded; 202/400 based on success count
- FR-010: scoped to admin endpoints only; ingest uses code: FEATURE_DISABLED
  in discardedEvents, not the 403 error envelope
- FR-011: live-toggle removal extended system-wide to all consumers of both
  flags, not only the gate; Key Entities updated accordingly
- US2 scenario 2 updated to 202 PARTIAL_SUCCESS; scenario 2b added for
  all-non-pageview batch → 400 ERROR
- US3 Independent Test updated: stripping produces no discardedEvents
- US4 Independent Test and scenario 1 updated: 400 not 403 for ingest
- Added SDK graceful degradation note (out of scope, follow-up task)
- Contract Changes section rewritten to document new ingest response shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
freddyDOTCMS and others added 2 commits September 25, 2026 14:12
…nly gates start/schedule

Analytics cannot be enabled without experiments, eliminating the
analytics=ON/experiments=OFF state entirely:

- Remove User Story 3 (Only Analytics Enabled — impossible state)
- Remove FR-007 (context.experiments stripping — only needed for the
  now-impossible analytics=ON/experiments=OFF case)
- Remove Payload mutation section from Contract Changes
- Remove SC-003 (analytics-only forwarding criterion)
- Remove context.experiments edge case

Experiment flag now only gates start/schedule operations:
- Rewrite FR-001: only _start and scheduling operations return 403
  FEATURE_DISABLED when FEATURE_FLAG_EXPERIMENTS=false; CRUD, results,
  _end, _cancel, isUserIncluded, and health remain accessible
- Update FR-002: all endpoints function normally when flag=true, including
  start/scheduling
- Update FR-002a: health endpoint is always accessible, no longer gated
- Update US3 (Both OFF): scenario 2 now reflects only start/schedule
  blocked; other experiment operations accessible
- Update SC-001: experiment start/scheduling blocked, not all endpoints
- Renumber FR-008→FR-007, FR-009→FR-008, FR-010→FR-009, FR-011→FR-010
- Renumber SC-004→SC-003, SC-005→SC-004, SC-006→SC-005
- Simplify Event Payload entity: context.experiments forwarded as-is
- Update Contract Changes experiments 403 scope to start/schedule only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…add limited experiment tier

Analytics is now gated by Analytics App configuration (per-site), not a
flag. FEATURE_FLAG_EXPERIMENTS remains as the only flag and now controls
a limited experiment tier rather than a binary on/off gate.

Key changes:
- Remove FEATURE_FLAG_CONTENT_ANALYTICS system-wide; analytics enabled
  when Analytics App is configured for the site
- Event ingest gated solely by App configuration (503 when not configured,
  forward as-is when configured — regardless of experiments flag)
- Analytics reads return 503 (not 403) when App not configured
- Experiment flag now enables a limited tier: 1 free experiment, max 10
  days, _start/_schedule/_abort/archive gated; CRUD/results always accessible
- Add US2: Limited Experiment Mode (flag=false, App configured, free slot)
  with real event collection, 10-day UI date picker restriction, and
  informational message
- Merge old US2/US3 into single "Limited/Disabled Mode" user story (US3)
- Add Analytics App as a Key Entity; remove FEATURE_FLAG_CONTENT_ANALYTICS
- Analytics health endpoint (GET /analytics/health) always returns
  structured OK/NOT_CONFIGURED/CONFIGURATION_ERROR — never proxies raw CAEM
- Experiments health endpoint extended with tier, freeExperimentUsed,
  and warning (ANALYTICS_DISABLED) fields
- Free slot count uses {RUNNING, SCHEDULED, ENDED} — ARCHIVED excluded
  since archiving is blocked in limited mode
- 400 returned when _start duration exceeds 10 days (not silent cap)
- Remove Independent Test sections from user stories (redundant)
- Update Legacy Considerations, Assumptions, and SC-001–SC-004

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dotCMS dotCMS deleted a comment from claude Bot Sep 28, 2026
…ing test declarations

- FR-009: remove siteauth from 403 scope; add unit test for error body contract
- FR-010: add test-suite audit requirement, scope constraint for live-toggle removal,
  FEATURE_FLAG_CAEM_EXPERIMENT_RESULTS and ENABLE_EXPERIMENTS_AUTO_JS_INJECTION left intact
- FR-010: add unit test for default flip (false when flag absent)
- FR-003a: declare health response shape ({ "health": "OK/NOT_CONFIGURED/CONFIGURATION_ERROR" })
- FR-001 / Contract Changes: _end moved to blocked list in all limited-mode states
- FR-006: fix gate ordering (configuration gate → site_auth → persistenceMode → forwarding)
- Key Entities: fix 403 → 503 for analytics read endpoints; fix site_auth ordering note
- Legacy Considerations: acknowledge FR-010 consumer sweep may touch com.dotmarketing.*
- FR-002a: add FEATURE_FLAG_CAEM_EXPERIMENT_RESULTS scope note (new fields apply when true only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sponse docs

Resolves three documentation gaps found by speckit-converge (Phase 8):

- ExperimentsHealthView: remove `allowableValues` from @Schema on `health`,
  `tier`, and `warning` fields — the declared enum type already provides the
  values; keeping both caused each value to appear twice in openapi.yaml
  (e.g. "FULL", "LIMITED", "FULL", "LIMITED"), producing a malformed schema.
- ExperimentsHealthView.Tier Javadoc: corrects "full"/"limited" → "FULL"/"LIMITED"
  to match the actual Jackson-serialized wire format.
- ExperimentsResource: add @apiresponse annotations to start(), end(), cancel(),
  and archive() documenting 200/403/503/400 gate responses — the return type
  change from ResponseEntitySingleExperimentView to Response left these
  operations with empty response schemas in openapi.yaml.
- Regenerate openapi.yaml after all annotation changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…R-003/US3/AC4)

GET /api/v1/experiments/{id}/results now returns 503 ANALYTICS_NOT_CONFIGURED
when the Analytics App is not configured for the site. Results depend on the
analytics backend; without it they are meaningless and must not be served.

Also adds @apiresponse documentation for the new 503 response case.
openapi.yaml regenerated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!

  • Category: M-3 — REST / GraphQL / Headless API Contract Change
  • Risk Level: 🟡 MEDIUM
  • Why it's unsafe: GET /v1/experiments/health changes its response entity type from Map<String, Health> (e.g. {"health":"OK"}) to a new structured ExperimentsHealthView object carrying health, tier, freeExperimentUsed, and warning. The new DTO's own Javadoc states it "Carries the existing health state plus the new tier fields that drive portlet button state in the frontend" — i.e. this is an API contract change meant to be consumed by a UI. If a frontend build that reads tier/freeExperimentUsed/warning is active during N's rollout and the backend is then rolled back to N-1, those fields vanish from the response (N-1 only returns the old Map<String, Health> shape), so any UI logic gating on tier or freeExperimentUsed silently receives undefined.
  • Code that makes it unsafe:
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentsResource.java — healthcheck() now returns ResponseEntityView<ExperimentsHealthView> instead of ResponseEntityView<Map<String, Health>>.
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentsHealthView.java — new response DTO (health, tier, freeExperimentUsed, warning), explicitly intended to drive frontend button state.
    • dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml — /v1/experiments/health response schema changed from ResponseEntityViewMapStringHealth to ResponseEntityViewExperimentsHealthView.
  • Alternative (if possible): Per M-3 guidance, give the shape change a backward-compatible transition — e.g. keep serializing a health top-level value compatible with the old Map<String, Health> consumers while the new tier/freeExperimentUsed/warning fields are additive-only, and avoid shipping frontend code that requires the new fields until N-1 is fully outside the rollback window.

Note: no database migrations, Elasticsearch mapping changes, or structural storage changes were found in this PR (FEATURE_FLAG_EXPERIMENTS default flip from true→false and the new limited-mode gating are pure in-memory/config and REST-layer behavior changes with no persisted schema impact).

…ponse

Adds @jsonvalue overrides to ExperimentsHealthView.Tier and Warning so they
serialize as "full"/"limited" and "analytics_disabled" instead of the default
uppercase enum names. This aligns the wire format with the spec examples in
FR-002a and data-model.md, while keeping Java enum names in UPPERCASE per
convention.

The FE TypeScript types and guards are updated separately (FE PR).
openapi.yaml regenerated: tier enum now shows "full"/"limited".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
freddyDOTCMS and others added 2 commits October 9, 2026 09:31
The Warning enum now serializes as "analytics_disabled" (lowercase via @jsonvalue).
Update the Postman test assertion from 'ANALYTICS_DISABLED' to 'analytics_disabled'
to match the actual wire format and keep the test consistent with the Tier enum
casing ("full"/"limited") agreed in this feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… wire values

Replaces @jsonvalue method with @JsonProperty on each enum constant:

  @JsonProperty("full")  FULL
  @JsonProperty("limited") LIMITED
  @JsonProperty("analytics_disabled") ANALYTICS_DISABLED

@JsonProperty is understood by both Jackson (serialization) and swagger-maven-
plugin (schema introspection), generating the correct lowercase enum values in
openapi.yaml without duplicates. The previous @jsonvalue + allowableValues
combination caused each value to appear twice in the schema.

Also fixes @Schema descriptions and removes allowableValues overrides — swagger
now reads the enum shape directly from @JsonProperty.
openapi.yaml: tier ["full","limited"], warning ["analytics_disabled"], each once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!

  • Category: M-3 — REST / GraphQL / Headless API Contract Change
  • Risk Level: 🟡 MEDIUM
  • Why it's unsafe: The GET /v1/experiments/health endpoint's response entity type changed from a plain Map<String, Health> (JSON: {\"health\": \"OK\"}) to the new ExperimentsHealthView object, which adds tier, freeExperimentUsed, and warning fields alongside health. This feature ships as part of the broader limited-mode/analytics-gating work (see specs/37659-analytics-experiment-flag-gates), and the Language.properties additions (experiments.list.limited-mode.banner, .slot-used, etc.) indicate the admin Experiments portlet UI is built to read tier/freeExperimentUsed from this endpoint to drive button/banner state. If N's frontend is deployed reading the new shape and the backend is rolled back to N-1, healthcheck() reverts to returning the old Map<String,Health> shape with no tier/freeExperimentUsed/warning keys — any N-era frontend code (cached in browsers or still deployed) that reads those fields gets undefined, breaking limited-mode gating UI for the Experiments portlet.
  • Code that makes it unsafe:
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentsResource.java — healthcheck() (~lines 1002-1068): return type changed from ResponseEntityView<Map<String, Health>> to ResponseEntityView<ExperimentsHealthView>.
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentsHealthView.java (new file): defines the new response shape (health, tier, freeExperimentUsed, warning).
    • dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml (~lines 10932-10934, ~37498): ResponseEntityViewMapStringHealth schema removed and replaced by ResponseEntityViewExperimentsHealthView.
  • Alternative (if possible): Keep the health key behavior identical and ship the new fields additively on the same Map-compatible shape (or a subclass of the old DTO) rather than swapping the entity type outright, so N-1 and N consumers can both parse the response. If a breaking shape change is unavoidable, document it as rollback-unsafe in release notes and confirm the Angular Experiments portlet degrades gracefully (e.g., defaults to full/legacy UI) when tier/freeExperimentUsed are absent, rather than breaking.

Moves LIMITED_MODE_MAX_DAYS to ExperimentsAPI interface (business layer)
so both ExperimentsAPIImpl.startLimitedScheduling() and
ExperimentLimitedModeGate reference the same constant.

ExperimentLimitedModeGate.MAX_DAYS delegates to ExperimentsAPI.LIMITED_MODE_MAX_DAYS,
keeping all limited-mode gate logic in one class while avoiding a reverse
dependency (business → REST).

ExperimentLimitedModeGate.defaultEndDate() remains the live implementation
used by the gate's duration check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mited-mode duration

- Make ExperimentLimitedModeGate public with MAX_DAYS = 10L and public defaultEndDate()
- ExperimentsAPIImpl.startLimitedScheduling() calls ExperimentLimitedModeGate.defaultEndDate(now)
  instead of duplicating the cap arithmetic — all limited-mode duration logic in one class
- ExperimentsAPI.LIMITED_MODE_MAX_DAYS removed — gate is the authority
- ExperimentsResource error message uses ExperimentLimitedModeGate.MAX_DAYS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
freddyDOTCMS and others added 4 commits October 9, 2026 11:45
…any body inspection

FR-006 requires the configuration gate to run before any payload inspection.
Previously the null-body guard (→400), JSON parse, and context checks all fired
before site resolution and the App-config gate (→503). Since getSiteFromRequest()
reads only HTTP headers (Origin/Referer), site resolution can be hoisted above the
body parse without any loss of information.

New order:
  1. getSiteFromRequest() — headers only, no body
  2. isAppConfigured() → 503 if absent  (gate fires before ANY body inspection)
  3. body null check → 400
  4. JSON parse / context checks → 400
  5. site_auth extraction + validation → 400
  6. persistence-mode check → 200/forward

Also extracts the repeated asyncResponse.resume(400) pattern into
resumeWithBadRequest() and the 503 body into analyticsNotConfiguredResponse(),
both private static helpers, reducing duplication across the method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes ~35 lines of builder boilerplate. The class becomes a compact record
with @JsonInclude(NON_NULL) — Jackson's record support handles nullable fields
cleanly without a hand-rolled builder.

- Builder replaced by canonical record constructor: new ExperimentsHealthView(health, tier, ...)
- Getters replaced by record accessors: view.health(), view.tier(), etc.
- Enum definitions and @JsonProperty annotations unchanged
- Test accessors updated: getHealth() → health(), getTier() → tier(), etc.
- openapi.yaml regenerated (no schema changes, only structural regeneration)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!

  • Category: M-3 - REST / GraphQL / Headless API Contract Change
  • Risk Level: MEDIUM
  • Why it is unsafe: Several Experiments REST endpoints change their response contract. ExperimentsResource.java changes archive, start, end, cancel, and getResult from returning typed POJOs (ResponseEntityExperimentView, ResponseEntitySingleExperimentView, ResponseEntityExperimentResults) to a generic javax.ws.rs.core.Response, and adds brand-new response codes (403 FEATURE_DISABLED, 503 ANALYTICS_NOT_CONFIGURED, 400 DURATION_EXCEEDED) that did not exist on N-1. Separately, GET /v1/experiments/health changes its JSON shape from Map<String, Health> ({"health": "OK"}) to the new ExperimentsHealthView DTO which adds tier, freeExperimentUsed, and warning fields. If the Angular admin frontend is updated in this same release cycle to branch on the new fields or status codes, then after a rollback to N-1 the frontend bundle calling the new shape against the old backend, or vice versa during a mixed-version rollback window, will get fields it does not expect or statuses it does not handle, producing broken UI states for the Experiments feature.
  • Code that makes it unsafe:
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentsResource.java: archive(), start(), end(), cancel(), getResult() signature changes (ResponseEntity* to Response), and healthcheck() return type change from ResponseEntityView<Map<String, Health>> to ResponseEntityView.
    • dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentsHealthView.java (new file): new wire shape with tier, freeExperimentUsed, warning.
    • dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml: several /v1/experiments/{id}/_start, _end, _archive, scheduled/{id}/_cancel paths lost their concrete response schema (now empty) because the Java return type is no longer a typed POJO; ResponseEntityViewMapStringHealth schema was replaced by ResponseEntityViewExperimentsHealthView.
  • Alternative (if possible): Per the M-3 guidance, additive-only contract evolution is the safer path: keep the old Map<String, Health>-shaped health key present alongside the new tier, freeExperimentUsed, and warning fields (already partially done via JsonInclude NON_NULL, which helps), and prefer typed response POJOs with Schema-documented alternate status payloads over switching to a bare Response return type, so the N-1 OpenAPI contract is not silently emptied. If the admin frontend must consume the new fields, stage the frontend rollout a release behind the backend change so a backend rollback does not leave a frontend bundle depending on fields the rolled-back API no longer serves.

freddyDOTCMS and others added 2 commits October 9, 2026 15:08
factory.list() loaded all experiment rows on every GET /health and POST /_start
call. The new ExperimentsFactory.countByStatuses(Set<Status>) issues a
SELECT COUNT(*) … UNION ALL query — one round-trip, no row hydration —
and isFreeSlotUsed() returns count > 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s disabled

When FEATURE_FLAG_CAEM_EXPERIMENT_RESULTS=false, health is derived from the
legacy dotExperiments-config path (AnalyticsHelper + EventLogRunnable test event)
rather than ContentAnalyticsUtil.resolveAnalyticsHealth() (CAEM path). The
warning field is CAEM-specific and is only populated on the true branch.
The response shape (ExperimentsHealthView) is unchanged in both branches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
// Build: SELECT COUNT(*) … WHERE status=? UNION ALL SELECT COUNT(*) … WHERE status=? …
// then sum the per-status counts in Java — one round-trip, no full-row fetch.
final StringBuilder sql = new StringBuilder(COUNT_BY_STATUS_BASE);
statuses.stream().skip(1).forEach(ignored -> sql.append(COUNT_BY_STATUS_UNION));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium severity blocking issue identified in your code:
The method identified is susceptible to injection. The input should be validated and properly
escaped.

Why this might be safe to ignore:

The SQL is assembled only from application-controlled constants, while each status value is supplied separately through addParam() as a bound parameter. No caller-controlled input is concatenated into SQL syntax, so this finding is not exploitable.

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by CUSTOM_INJECTION-2.

If this is a critical or high severity finding, please also link this issue in the #security channel in Slack.

You can view more details about this finding in the Semgrep AppSec Platform.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants