diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolver.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolver.java new file mode 100644 index 000000000000..5e83af46cd1e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ExperimentCreatorNameResolver.java @@ -0,0 +1,124 @@ +package com.dotcms.rest.api.v1.experiments; + +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.NoSuchUserException; +import com.dotmarketing.business.UserAPI; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.User; +import java.util.function.Supplier; + +/** + * Turns an {@code Experiment.createdBy()} user ID into the display name published as + * {@code createdByUserName} (#37304). + * + *
The field is never null, absent or empty. The system user is reported as + * {@code "System"}, and everything that cannot be resolved to a named user — a deleted user, an + * orphaned reference, a user with no name set, or a lookup that blows up — is reported as + * {@code "unknown"}. + * + *
The two labels are borrowed from {@link com.dotcms.browser.BrowserAPIImpl}'s {@code ownerName}, + * which answers the same question for the Content Drive folder view: two listings in the same + * product labelling the same orphaned owner differently is a worse outcome than either label on its + * own. The alignment is exact for the deleted/orphaned and system-user cases and deliberately + * stricter in two others: Content Drive publishes the raw {@code getFullName()} for a blank-named + * user (a bare space) and {@code null} for an unset id, where this resolver reports + * {@code "unknown"} so the field is never blank. Content Drive also reaches the user through + * {@link com.liferay.portal.ejb.UserLocalManagerUtil} and so bypasses {@code UserCache}; this one + * deliberately does not. + * + *
No checked or runtime exception may escape. The caller is a serializer running inside + * an already-successful API response, so an exception escaping this class would turn a working + * experiment read into a failed request over a field that is only decoration. {@code Error} and its + * subclasses are deliberately not caught. + * + *
Cost. {@code UserFactoryImpl.loadUserById} consults {@code UserCache} before the
+ * database, so a listing whose experiments share a resolvable creator costs one database
+ * read for the first row and an in-memory hit for every row after it. An unresolvable
+ * creator is the exception worth knowing about: a miss is not negative-cached, so every row
+ * referencing an orphaned id pays its own query and its own {@code NoSuchUserException}. The same
+ * residual limitation is documented for Content Drive on
+ * {@code BrowserAPIImpl#warmUpUserCache}. This class deliberately adds no memo of its own: the
+ * value is resolved per serialization so that a user who renames themselves is reported under the
+ * new name.
+ */
+public class ExperimentCreatorNameResolver {
+
+ /** Shown for an owner that cannot be resolved to a named user. Matches Content Drive. */
+ static final String UNKNOWN = "unknown";
+
+ /** Shown for the system user. Matches Content Drive. */
+ static final String SYSTEM = "System";
+
+ /** One warning per minute when the user layer is failing, rather than one per experiment. */
+ private static final int WARN_THROTTLE_MILLIS = 60_000;
+
+ /**
+ * The instance used by the model. The {@link UserAPI} is supplied lazily rather than captured,
+ * so class initialization never depends on {@code APILocator} being ready.
+ */
+ public static final ExperimentCreatorNameResolver INSTANCE =
+ new ExperimentCreatorNameResolver(APILocator::getUserAPI);
+
+ private final Supplier The derivation lives here rather than on the model on purpose. {@link Experiment} is a data
+ * object, and anything computed on it is computed everywhere it is serialized — which is not only
+ * REST. Push-publish bundling and starter export serialize the whole Experiment too, and their
+ * receiver reads it back through a mapper that rejects unknown properties and rolls back the entire
+ * bundle when it finds one. Keeping the name in the view means only the endpoint response carries
+ * it, the model stays a plain POJO, and no other serialization path pays a user lookup.
+ *
+ * The experiment is {@link JsonUnwrapped}, so the payload is the Experiment's own fields with
+ * {@code createdByUserName} beside them rather than nested — the wire contract consumers see is a
+ * flat object, unchanged except for the added field.
+ */
+public class ExperimentView {
+
+ private final Experiment experiment;
+
+ /**
+ * Wraps an Experiment for a REST response.
+ *
+ * @param experiment the experiment to publish
+ * @return a view that serializes the experiment plus its creator's display name
+ */
+ public static ExperimentView of(final Experiment experiment) {
+ return new ExperimentView(experiment);
+ }
+
+ private ExperimentView(final Experiment experiment) {
+ this.experiment = experiment;
+ }
+
+ @JsonUnwrapped
+ public Experiment getExperiment() {
+ return this.experiment;
+ }
+
+ /**
+ * The display name of the user behind the experiment's {@code createdBy}, resolved while the
+ * response is written and never stored, so a creator who renames themselves is reported under
+ * the new name. Reports {@code "System"} for the system user and {@code "unknown"} when the
+ * creator cannot be resolved or has no name set, matching the labels the Content Drive folder
+ * view already uses. Never null, never blank.
+ *
+ * @return the creator's display name
+ */
+ @JsonProperty("createdByUserName")
+ @Schema(description = "Display name of the user who created the experiment. Reports \"System\" "
+ + "for the system user and \"unknown\" when the user cannot be resolved or has no name "
+ + "set, so the value is never empty.",
+ example = "Admin User")
+ public String getCreatedByUserName() {
+ return ExperimentCreatorNameResolver.INSTANCE.resolve(this.experiment.createdBy());
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java
index d925663677a0..35c2ef74b986 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/experiments/ResponseEntityExperimentView.java
@@ -3,9 +3,10 @@
import com.dotcms.experiments.model.Experiment;
import com.dotcms.rest.ResponseEntityView;
import java.util.List;
+import java.util.stream.Collectors;
-public class ResponseEntityExperimentView extends ResponseEntityView The whole value of this field is that the Experiments portlet's Created By column is
+ * never blank. That promise is not made by the happy path — it is made by the failure
+ * branches, so each one gets its own test: a user who no longer exists, a user who exists but has
+ * no name set, and an infrastructure failure. All three collapse to the same answer,
+ * {@code "unknown"}, and none of them may throw: a decoration failure must never turn a successful
+ * experiment read into a failed request (FR-009, FR-010, FR-011).
+ *
+ * The labels deliberately match {@code BrowserAPIImpl.ownerName}, which answers the same question
+ * for the Content Drive folder view: {@code "System"} for the system user, {@code "unknown"} for
+ * anyone who cannot be resolved. Two listings in the same product should not label the same
+ * orphaned owner differently.
+ *
+ * The resolver takes its {@link UserAPI} through the constructor precisely so these branches can
+ * be exercised without a database; production code reaches it through {@code APILocator}.
+ */
+class ExperimentCreatorNameResolverTest {
+
+ private static final String CREATOR_ID = "dotcms.org.1";
+ private static final String UNKNOWN = "unknown";
+
+ private UserAPI userAPI;
+ private ExperimentCreatorNameResolver resolver;
+
+ @BeforeEach
+ void setUp() {
+ userAPI = mock(UserAPI.class);
+ resolver = new ExperimentCreatorNameResolver(() -> userAPI);
+ }
+
+ private static User userNamed(final String first, final String middle, final String last) {
+ final User user = new User();
+ user.setFirstName(first);
+ user.setMiddleName(middle);
+ user.setLastName(last);
+ return user;
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: The creator ID resolves to a User with a first and last name.
+ * ExpectedResult: The user's full name is returned.
+ */
+ @Test
+ void resolve_userWithName_returnsFullName() throws Exception {
+ when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("Admin", "", "User"));
+
+ assertEquals("Admin User", resolver.resolve(CREATOR_ID));
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: The creator resolves to a User whose first, middle and last name are all
+ * blank. {@code User.getFullName()} joins the blank parts with a space, so it
+ * returns {@code " "} — a single space, NOT the empty string.
+ * ExpectedResult: {@code "unknown"} — the field is never blank (FR-003, FR-010). Note what
+ * makes this work: {@code UtilMethods.isSet} trims before measuring length.
+ * A non-trimming emptiness check would let the space through to the column.
+ */
+ @Test
+ void resolve_userWithBlankName_fallsBackToUnknown() throws Exception {
+ when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("", "", ""));
+
+ assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID));
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: The creator ID points at a user that no longer exists — a deleted user, or an
+ * orphaned reference left behind by one.
+ * ExpectedResult: {@code "unknown"}, and no exception escapes (FR-009).
+ */
+ @Test
+ void resolve_deletedUser_fallsBackToUnknown() throws Exception {
+ when(userAPI.loadUserById(CREATOR_ID))
+ .thenThrow(new NoSuchUserException("No user matches " + CREATOR_ID));
+
+ assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID));
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: The user lookup fails for an infrastructure reason rather than a data one.
+ * ExpectedResult: {@code "unknown"}. This is the branch that keeps a database hiccup from
+ * turning {@code GET /v1/experiments} into a 500 (FR-011).
+ */
+ @Test
+ void resolve_lookupFailure_fallsBackToUnknownAndDoesNotThrow() throws Exception {
+ when(userAPI.loadUserById(CREATOR_ID)).thenThrow(new DotDataException("boom"));
+
+ assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID));
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: An unexpected unchecked failure escapes the user layer.
+ * ExpectedResult: Still {@code "unknown"}. The catch is deliberately broad because the caller
+ * is a serializer: anything thrown here would surface as a failed API response
+ * for a field that is only decoration.
+ */
+ @Test
+ void resolve_unexpectedRuntimeFailure_fallsBackToUnknown() throws Exception {
+ when(userAPI.loadUserById(CREATOR_ID)).thenThrow(new IllegalStateException("unexpected"));
+
+ assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID));
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: The creator has a first name but no last name (or the reverse).
+ * {@code User.getFullName()} concatenates unconditionally with a space and
+ * does not trim, so it hands back {@code "Admin "}.
+ * ExpectedResult: {@code "Admin"} — the padding must not reach the portlet's Created By
+ * column. This is the branch adjacent to the all-blank one, and the reason
+ * the resolver trims rather than trusting the source.
+ */
+ @Test
+ void resolve_userWithOnlyAFirstName_returnsTheNameWithoutPadding() throws Exception {
+ when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("Admin", "", ""));
+
+ assertEquals("Admin", resolver.resolve(CREATOR_ID));
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: The user layer returns {@code null} instead of throwing. The production
+ * {@code UserAPIImpl} throws {@link NoSuchUserException} rather than returning
+ * null, so this pins a defensive branch reachable through any other
+ * {@code UserAPI} implementation or decorator.
+ * ExpectedResult: {@code "unknown"}, with no NullPointerException.
+ */
+ @Test
+ void resolve_nullUser_fallsBackToUnknown() throws Exception {
+ when(userAPI.loadUserById(CREATOR_ID)).thenReturn(null);
+
+ assertEquals(UNKNOWN, resolver.resolve(CREATOR_ID));
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: The experiment was created by the system user, whose ID is {@code "system"}.
+ * ExpectedResult: {@code "System"}, without consulting the user layer at all — the same
+ * short-circuit {@code BrowserAPIImpl.ownerName} applies, so the two listings
+ * agree on the label.
+ */
+ @Test
+ void resolve_systemUser_returnsSystemWithoutLookup() throws Exception {
+ assertEquals("System", resolver.resolve("system"));
+ assertEquals("System", resolver.resolve("SYSTEM"));
+
+ verify(userAPI, never()).loadUserById(anyString());
+ }
+
+ /**
+ * Method to test: {@link ExperimentCreatorNameResolver#resolve(String)}
+ * Given Scenario: An unset creator ID. {@code AbstractExperiment.createdBy()} is a mandatory
+ * attribute, so the model cannot actually produce this — the test pins the
+ * defensive behaviour rather than a reachable path.
+ * ExpectedResult: {@code "unknown"}, and the user layer is never consulted, so a blank ID
+ * cannot cost a lookup or raise.
+ */
+ @Test
+ void resolve_unsetId_returnsUnknownAndSkipsLookup() throws Exception {
+ assertEquals(UNKNOWN, resolver.resolve(""));
+ assertEquals(UNKNOWN, resolver.resolve(null));
+
+ verify(userAPI, never()).loadUserById(anyString());
+ verifyNoMoreInteractions(userAPI);
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentViewTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentViewTest.java
new file mode 100644
index 000000000000..34b4a36fad15
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/experiments/ExperimentViewTest.java
@@ -0,0 +1,260 @@
+package com.dotcms.rest.api.v1.experiments;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.dotcms.experiments.model.Experiment;
+import com.dotcms.publishing.BundlerUtil;
+import com.dotcms.rest.api.v1.DotObjectMapperProvider;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.business.UserAPI;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.liferay.portal.model.User;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+/**
+ * Serialization tests for {@link ExperimentView}, the REST-layer wrapper that publishes
+ * {@code createdByUserName} beside an Experiment (#37304).
+ *
+ * Why the field lives here and not on the model. An earlier revision of this work put the
+ * accessor on {@code AbstractExperiment} itself. That made every serialization of an Experiment
+ * resolve a user — including push-publish bundling and starter export, which are not REST responses
+ * at all, and which are read back by a receiver that rejects unknown properties and rolls back the
+ * whole bundle. Keeping the derivation in the view means the model stays a plain data object and
+ * only the endpoint response is affected, which is what review asked for.
+ *
+ * The wire shape must not change as a result: {@code createdByUserName} is a sibling of the
+ * Experiment's own fields, not a nested object. {@link #view_flattensTheExperimentAlongsideTheName()}
+ * is what pins that.
+ */
+class ExperimentViewTest {
+
+ private static final String CREATOR_ID = "dotcms.org.1";
+ private static final String CREATOR_NAME = "Admin User";
+ private static final String MODIFIER_ID = "dotcms.org.2";
+
+ private static Experiment anExperiment() {
+ return Experiment.builder()
+ .name("Homepage CTA test")
+ .pageId("2d8b8b1e-0000-0000-0000-000000000001")
+ .createdBy(CREATOR_ID)
+ .lastModifiedBy(MODIFIER_ID)
+ .id("0e8b8b1e-0000-0000-0000-000000000002")
+ .lookBackWindowExpireTime(1_800_000L)
+ .build();
+ }
+
+ private static User userNamed(final String first, final String last) {
+ final User user = new User();
+ user.setFirstName(first);
+ user.setMiddleName("");
+ user.setLastName(last);
+ return user;
+ }
+
+ private static void withResolvableCreator(final ThrowingRunnable body) throws Exception {
+ final UserAPI userAPI = mock(UserAPI.class);
+ when(userAPI.loadUserById(CREATOR_ID)).thenReturn(userNamed("Admin", "User"));
+ when(userAPI.loadUserById(MODIFIER_ID)).thenReturn(userNamed("Other", "Person"));
+
+ try (MockedStatic> {
+public class ResponseEntityExperimentView extends ResponseEntityView
> {
public ResponseEntityExperimentView(final List
>` | **None** — inherits the field |
+
+## Endpoints that must carry the field
+
+All 14 inherit it without being edited. Enumerated in [../spec.md](../spec.md#endpoints-in-scope):
+create, `PATCH`, `_archive`, `GET /{id}`, `GET` list, `DELETE /goals/primary`, `_start`, `_end`,
+`scheduled/{id}/_cancel`, `POST /variants`, `DELETE /variants/{name}`, `PUT /variants/{name}`,
+`PUT /variants/{name}/_promote`, `DELETE /targetingConditions/{id}`.
+
+Explicitly **not** carrying it, because they return no Experiment:
+`DELETE /v1/experiments/{experimentId}` (returns the string `"Experiment deleted"`),
+`POST /isUserIncluded`, `GET /{id}/results`, `GET /health`.
+
+## Behavioural contract
+
+| Condition | `createdBy` | `createdByUserName` | HTTP |
+|---|---|---|---|
+| Creator resolves, has a name | user id | full name (first + middle + last) | 200 |
+| Creator resolves, all name parts blank | user id | `unknown` | 200 |
+| Creator does not resolve (deleted/orphaned) | user id | `unknown` | 200 |
+| User lookup fails (infrastructure) | user id | `unknown` | 200 |
+| Creator is the system user | `system` | `System`, short-circuited before any lookup | 200 |
+| One bad creator among many in a list | per entry | only the affected entry falls back | 200 |
+
+The two fallback labels are the ones `BrowserAPIImpl.ownerName` already publishes for the Content
+Drive folder view, so the same orphaned owner reads the same in both listings.
+
+The field is never `null`, never absent and never `""`.
+
+## Where the field is produced
+
+`ExperimentView` (`com.dotcms.rest.api.v1.experiments`) wraps the Experiment for a response and adds
+the field; the Experiment is `@JsonUnwrapped`, so the payload stays a flat object and the wire shape
+is unchanged except for the addition. `ResponseEntitySingleExperimentView` and
+`ResponseEntityExperimentView` do the wrapping, so none of the 14 endpoints changed.
+
+The `Experiment` schema in `openapi.yaml` is therefore untouched, and a new `ExperimentView` schema
+carries the flattened fields plus `createdByUserName`. Both response wrappers now `$ref` the view.
+
+Because the model is a plain data object again, the field does **not** reach push-publish bundles or
+starter exports, and those paths pay no user lookup — no mix-in or other mitigation is needed.
+
+## Deserialization contract (FR-023)
+
+`createdByUserName` is **read-only**: serialized on the way out, ignored on the way in. A payload
+containing it must still deserialize into an `Experiment` without error.
+
+With the field on the view rather than the model, this is now free: the Experiment payload has no
+serialize-only property, so it round-trips exactly as it did before this change. `ExperimentView` is
+a response type and is never deserialized. The guard is `bareExperiment_stillRoundTripsAndKeepsItsContract`.
+
+## Verification
+
+```bash
+# Regenerate and inspect the contract
+JAVA_HOME="$HOME/.sdkman/candidates/java/25.0.2-ms" \
+ ./mvnw compile -pl :dotcms-core --am -DskipTests -Ddocker.skip
+
+grep -A 8 '^ Experiment:' dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
+git diff --stat dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml # must show the added property
+```
+
+The committed yaml must match what the build produces, or the CI contract check fails (FR-019).
diff --git a/specs/37304-experiment-created-by-username/data-model.md b/specs/37304-experiment-created-by-username/data-model.md
new file mode 100644
index 000000000000..ac341f2fec94
--- /dev/null
+++ b/specs/37304-experiment-created-by-username/data-model.md
@@ -0,0 +1,127 @@
+# Phase 1 Data Model: Creator Name on the Experiments API
+
+**Feature**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md)
+
+No persistent data model changes. No table, column, index, migration or upgrade task. This document
+describes the **in-memory and wire** shape only.
+
+---
+
+## Entity: Experiment (`com.dotcms.experiments.model.AbstractExperiment`)
+
+### Existing members that this change must not disturb
+
+| Member | Kind | JSON | Invariant to preserve |
+|---|---|---|---|
+| `createdBy()` | abstract attribute, `String` | `createdBy` | Key, value (the creator's **user id**) and meaning unchanged — FR-005. Persisted in `experiment.created_by`. |
+| `getOwner()` | `@Value.Derived`, `@JsonIgnore` | *(absent)* | Still returns `createdBy()`. Permission behaviour unchanged — FR-006. Stays out of the payload. |
+| `lastModifiedBy()` | abstract attribute, `String` | `lastModifiedBy` | Untouched, no name companion — FR-008. |
+
+### New member
+
+| Property | Value |
+|---|---|
+| **Name** | `createdByUserName` |
+| **Java** | `default String createdByUserName()` on `AbstractExperiment` |
+| **Immutables kind** | **None** — deliberately not `@Value.Default/Derived/Lazy`, so it is not an attribute: no field, no builder method, no `equals`/`hashCode`/`toString` participation, no `build()` cost |
+| **Jackson** | `@JsonProperty(value = "createdByUserName", access = JsonProperty.Access.READ_ONLY)` |
+| **Swagger** | `@Schema(description = "...", example = "Admin User")` on the same method |
+| **Type** | `String`, **always non-null and non-empty** (FR-003) — a real name, `System`, or `unknown` |
+| **Persisted** | No — resolved per serialization (FR-016, FR-017) |
+| **Settable** | No — read-only by design; `READ_ONLY` is what keeps an inbound payload parseable (FR-023) |
+| **Derivation** | `ExperimentCreatorNameResolver.resolve(createdBy())` |
+
+### Evaluation timing (the property that makes the design work)
+
+| Path | Builds an Experiment? | Resolves the name? |
+|---|---|---|
+| `ExperimentTransformer` (every DB row: `find`, `list`) | Yes | **No** |
+| `addTargetingConditions` → `withTargetingConditions` rebuild on `find` | Yes | **No** |
+| `cacheRunningExperiments()` → running-experiments list cache (page render) | Yes | **No** — FR-015 |
+| Push-publish dependency walk (`DependencyManager`) | Yes | **No** — FR-015 |
+| Jackson serialization of a REST response | No | **Yes** — once per experiment per response |
+
+A `@Value.Derived` member would answer "Yes" in every row of that table; a `@Value.Lazy` member
+would answer "Yes" once per instance and then memoize it — including in the shared, long-lived
+running-experiments cache entries. See [research.md](./research.md) R1/R2.
+
+---
+
+## Derivation rule: `ExperimentCreatorNameResolver`
+
+Input: the raw `createdBy` user id. Output: a non-empty display string.
+
+```
+resolve(createdById):
+ 1. createdById not set -> return "unknown" (never null/empty; FR-003)
+ 2. createdById is "system" -> return "System" (no lookup at all; FR-010a)
+ 3. user = userAPI.loadUserById(createdById) # UserCache-backed (research R3)
+ 4. fullName = user.getFullName() # first + middle + last
+ 5. fullName is set -> return fullName
+ 6. otherwise -> return "unknown" # blank-name user; FR-010
+ on NoSuchUserException -> return "unknown" # deleted / orphaned; FR-009
+ on DotDataException / any other -> return "unknown" # never fail the request; FR-011
+```
+
+The two labels are `BrowserAPIImpl.ownerName`'s, which answers the same question for the Content
+Drive folder view (FR-010b). That implementation reaches the user through `UserLocalManagerUtil` and
+so bypasses `UserCache`; this one deliberately goes through `APILocator.getUserAPI()` instead.
+
+**Per-entry isolation** (FR-012): the rule is applied independently per experiment, so one bad id in
+a list degrades exactly one entry.
+
+**Logging** (FR-013): `Logger.debug` for the expected not-found case — it is a data condition, not
+an error, and debug is off by default, so a listing full of orphaned creators cannot flood the log.
+`Logger.warn` for an unexpected infrastructure failure, which is rare by nature and worth surfacing.
+
+**Testability seam**: the resolver accepts a `UserAPI` so every branch above is unit-testable with a
+mock; production code goes through `APILocator.getUserAPI()`.
+
+---
+
+## Wire shape
+
+Before:
+
+```json
+{
+ "id": "0e8b8b1e-...",
+ "name": "Homepage CTA test",
+ "createdBy": "dotcms.org.1",
+ "lastModifiedBy": "dotcms.org.1"
+}
+```
+
+After — one added key, nothing else moved:
+
+```json
+{
+ "id": "0e8b8b1e-...",
+ "name": "Homepage CTA test",
+ "createdBy": "dotcms.org.1",
+ "createdByUserName": "Admin User",
+ "lastModifiedBy": "dotcms.org.1"
+}
+```
+
+Unresolvable creator (deleted user, or a user whose name parts are all blank):
+
+```json
+{
+ "createdBy": "deleted-user-id-4711",
+ "createdByUserName": "unknown"
+}
+```
+
+Created by the system user:
+
+```json
+{
+ "createdBy": "system",
+ "createdByUserName": "System"
+}
+```
+
+Property order in real responses is alphabetical — `DotObjectMapperProvider.createDefaultMapper()`
+enables `SORT_PROPERTIES_ALPHABETICALLY` when `dotcms.rest.sort.json.properties` is true (the
+default), which places `createdByUserName` immediately after `createdBy`.
diff --git a/specs/37304-experiment-created-by-username/spec.md b/specs/37304-experiment-created-by-username/spec.md
new file mode 100644
index 000000000000..cb6df07d6c2f
--- /dev/null
+++ b/specs/37304-experiment-created-by-username/spec.md
@@ -0,0 +1,368 @@
+# Feature Specification: Creator Name on the Experiments API
+
+**Feature Branch**: `issue-37304-experiment-created-by-username`
+
+**Created**: 2026-09-10
+
+**Status**: Draft
+
+**Type**: Task (additive, non-breaking API contract change)
+
+**Epic**: [#36763 — Experiments: A/B Testing v2](https://github.com/dotCMS/core/issues/36763)
+
+**Work item**: [dotCMS/core#37304 — Expose the experiment creator's username in the Experiments API](https://github.com/dotCMS/core/issues/37304)
+
+**Input**: User description: "Expose the experiment creator's username in the Experiments API" — taken from issue #37304.
+
+---
+
+## Scope Note *(read this first)*
+
+Every Experiment already records who created it, but only as an opaque user ID. The payload says
+`"createdBy": "dotcms.org.1"` and nothing else. That ID is not a name: it is the same value the
+permission layer uses as the experiment's owner, and it means nothing to the person reading a
+listing.
+
+The new Experiments portlet wants a **Created By** column. With today's contract the portlet has two
+bad options: render the raw ID, or issue a second round of requests to translate every ID it sees
+into a name. Both push work onto the client that the server can do once, cheaply, from data it has
+already loaded.
+
+This work adds one field beside the existing one: `createdByUserName`, carrying the creator's full
+name. It is **additive**. `createdBy` keeps its key, keeps its value and keeps its meaning, and the
+owner/permission behaviour that reads it is untouched. Nothing that consumes the current payload has
+to change.
+
+Because the field belongs to the Experiment itself and not to one endpoint's response shape, it
+arrives on *every* response that carries an Experiment — the listing, the single fetch, and each of
+the lifecycle and variant operations that return the updated experiment.
+
+Two things this work deliberately does **not** do. It does not touch `lastModifiedBy`, which has the
+exact same ID-not-name problem and is left for a follow-up. And it does not store the name: the
+value is resolved when the experiment is read, so an experiment created by someone who later changes
+their name does not keep serving the old one.
+
+---
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - See who created each experiment in the listing (Priority: P1)
+
+A marketer opens the Experiments portlet and scans the list of experiments for the site. Each row
+tells them who created that experiment by name — "Admin User", not "dotcms.org.1" — so they can find
+their own work, or find the colleague to ask about someone else's.
+
+**Why this priority**: This is the whole point of the change and the reason the consumer issue
+(#37307, listing Created By column) is blocked. The listing is where an ID is least useful, because
+the reader is comparing many rows at once.
+
+**Independent Test**: Create experiments owned by two different known users, request the experiment
+list, and confirm each entry reports the corresponding user's full name.
+
+**Acceptance Scenarios**:
+
+1. **Given** an experiment created by a user whose full name is "Admin User", **When** the experiment
+ list is requested, **Then** that experiment's entry reports `createdBy` unchanged as the user ID
+ **and** reports the creator's name as "Admin User".
+2. **Given** a list containing experiments created by several different users, **When** the list is
+ requested, **Then** each entry reports the name of its own creator.
+3. **Given** a list containing many experiments created by the *same* user, **When** the list is
+ requested, **Then** every entry reports that user's name and the response is served without a
+ perceptible slowdown compared with the same list before this change.
+
+---
+
+### User Story 2 - The name travels with the experiment everywhere (Priority: P2)
+
+A client that has just created, started, ended, archived or otherwise modified an experiment gets the
+updated experiment back and can render the creator's name from that response alone, without a
+follow-up fetch and without special-casing which operation it just performed.
+
+**Why this priority**: Without it, a portlet that renders a row from a create/update response and a
+row from the listing has to handle two different shapes for the same object. It is also what makes
+the change cheap: one place to resolve the name, not fourteen.
+
+**Independent Test**: Exercise each endpoint whose response carries an Experiment and confirm the
+creator's name is present in each one.
+
+**Acceptance Scenarios**:
+
+1. **Given** an existing experiment, **When** it is fetched by ID, **Then** the response carries the
+ creator's name.
+2. **Given** an existing draft experiment, **When** it is started, ended, cancelled, archived,
+ updated, or has a variant added, removed, renamed or promoted, **Then** each of those responses
+ carries the creator's name for that experiment.
+3. **Given** an experiment created through the create endpoint, **When** the create response is read,
+ **Then** it already carries the creator's name — no second request is needed.
+
+---
+
+### User Story 3 - The column is never blank (Priority: P3)
+
+An administrator looks at a listing that includes experiments created by a user who has since been
+deleted, and by the system itself. Every row still shows something identifying in the Created By
+column, and no row is missing or empty. The listing loads normally.
+
+**Why this priority**: A rare data condition must not produce a broken-looking column or, worse, a
+failed request that hides every other experiment in the list.
+
+**Independent Test**: Point an experiment's `createdBy` at a user ID that no longer resolves, request
+both the list and the single fetch, and confirm the response succeeds and reports `unknown`.
+
+**Acceptance Scenarios**:
+
+1. **Given** an experiment whose creator ID does not resolve to any user, **When** the experiment is
+ requested, **Then** the request succeeds and the creator name field reports `unknown`.
+2. **Given** an experiment created by the system user, **When** the experiment is requested, **Then**
+ the request succeeds and the creator name field reports `System`.
+3. **Given** a list where one experiment has an unresolvable creator and the others do not, **When**
+ the list is requested, **Then** the request succeeds, the resolvable entries report real names and
+ only the affected entry reports `unknown`.
+
+---
+
+### Edge Cases
+
+- **Creator no longer exists** (deleted user, orphaned reference): the field reports `unknown`. It is
+ never null, never absent and never an empty string.
+- **Creator resolves but has no usable name** (first, middle and last name all blank): treated the
+ same as unresolvable — report `unknown`, so the column still says something honest.
+- **Creator is the system user**: reported as `System`, short-circuited before any lookup. The
+ request never fails because of it.
+- **User lookup fails for an infrastructure reason** (database error, cache error): the experiment is
+ still returned successfully with the fallback value. A failure to decorate must never turn a
+ successful experiment read into a failed request.
+- **Same creator repeated across a long list**: resolving the name must not cost one uncached lookup
+ per row.
+- **Creator renames themselves**: subsequent reads report the new name. The value is resolved from
+ current user data at read time, not captured when the experiment was created.
+- **An experiment payload is read back in** (a client echoing a response, a future import path): the
+ extra field must not make that payload unreadable.
+- **Experiment with no variants / archived / ended**: status has no bearing on the field; it is
+ present in every state.
+
+---
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+**The field**
+
+- **FR-001**: Every API response that carries an Experiment MUST include a creator-name field,
+ `createdByUserName`, alongside the existing `createdBy`.
+- **FR-002**: `createdByUserName` MUST carry the full name of the user identified by that
+ experiment's `createdBy` value.
+- **FR-003**: `createdByUserName` MUST always be present and non-empty. It is never null, never
+ omitted, and never an empty string.
+- **FR-004**: The field MUST be a single display-ready string. The API is not required to expose
+ first name and last name separately.
+
+**Nothing existing changes**
+
+- **FR-005**: `createdBy` MUST keep its current JSON key and its current value (the user ID). No
+ consumer of the present contract may break.
+- **FR-006**: Owner and permission behaviour MUST be unchanged: the experiment's owner continues to
+ resolve from `createdBy`, never from the new field.
+- **FR-007**: No other existing Experiment field may be removed, renamed, retyped or reordered by
+ this work. The change is strictly additive.
+- **FR-008**: `lastModifiedBy` MUST be left exactly as it is today — same key, same value, no name
+ companion. It is out of scope (see Out of Scope).
+
+**Fallback and failure**
+
+- **FR-009**: When the creator ID does not resolve to a user, `createdByUserName` MUST report
+ `unknown`.
+- **FR-010**: When the creator resolves to a user whose name is blank, `createdByUserName` MUST
+ report `unknown` under the same rule as FR-009.
+- **FR-010a**: When the creator is the system user, `createdByUserName` MUST report `System`,
+ without a user lookup.
+- **FR-010b**: These labels MUST match the ones the Content Drive folder view already publishes for
+ the same question (`BrowserAPIImpl.ownerName`): `System` for the system user, `unknown` for an
+ owner that cannot be resolved. Two listings in the same product must not label the same orphaned
+ owner differently.
+- **FR-011**: A failure to resolve the creator MUST NOT fail the experiment request. The endpoint
+ still returns its normal success response with the experiment payload and the fallback value.
+- **FR-012**: A failure to resolve one experiment's creator inside a list MUST NOT affect the other
+ entries in that list.
+- **FR-013**: Resolution failures MUST be observable to an operator (logged), without the log line
+ being emitted once per row of a large listing.
+
+**Cost and freshness**
+
+- **FR-014**: Serving a list of N experiments MUST NOT perform N uncached user lookups. Resolution is
+ per distinct creator, and repeated creators cost no additional database work.
+- **FR-015**: The change MUST NOT add user lookups to code paths that do not serialize an Experiment
+ — in particular the running-experiment selection performed during page rendering, and the
+ push-publish dependency walk. Reading an experiment for those purposes must cost what it costs
+ today.
+- **FR-016**: `createdByUserName` MUST reflect the creator's current name. The name MUST NOT be
+ captured at experiment-creation time and stored beside the experiment.
+- **FR-017**: This work MUST NOT introduce a database schema change. No new column, no migration, no
+ upgrade task.
+
+**Contract, docs and tests**
+
+- **FR-018**: `createdByUserName` MUST be documented in the API schema for the Experiment, with a
+ description that states it is the creator's display name and that it falls back to the creator ID.
+ The documented type MUST match what is actually returned.
+- **FR-019**: The committed `openapi.yaml` MUST be regenerated from the annotations and committed
+ together with the code change, so the CI contract check passes.
+- **FR-020**: Integration tests MUST cover: the happy path from the list endpoint, the happy path
+ from the single-fetch endpoint, and the unresolvable-creator fallback returning success plus
+ `unknown`.
+- **FR-021**: Every new integration test class MUST be registered in a `MainSuite*` / `Junit5Suite*`
+ `@SuiteClasses` list, otherwise it never runs in CI.
+- **FR-022**: Existing experiment tests MUST continue to pass unchanged, in particular any asserting
+ on the shape or value of `createdBy`.
+- **FR-023**: An Experiment payload that contains `createdByUserName` MUST still be readable wherever
+ Experiment JSON is parsed back into an Experiment. The added field must not make a round-tripped
+ payload fail to parse.
+- **FR-023a**: `createdByUserName` MUST NOT appear on the `Experiment` model itself, and therefore
+ MUST NOT reach push-publish bundles or starter exports. It is published by the REST view only, so
+ the endpoint response carries it and nothing else does.
+
+### Endpoints in scope
+
+Every endpoint below returns a payload carrying one or more Experiments and is therefore covered by
+FR-001. This list is taken from the resource as it exists today, not from the issue body (see
+Assumptions, A7):
+
+| # | Endpoint |
+|---|---|
+| 1 | `POST /v1/experiments` (create) |
+| 2 | `PATCH /v1/experiments/{experimentId}` (partial update) |
+| 3 | `PUT /v1/experiments/{experimentId}/_archive` |
+| 4 | `GET /v1/experiments/{id}` |
+| 5 | `GET /v1/experiments` (list) |
+| 6 | `DELETE /v1/experiments/{experimentId}/goals/primary` |
+| 7 | `POST /v1/experiments/{experimentId}/_start` |
+| 8 | `POST /v1/experiments/{experimentId}/_end` |
+| 9 | `POST /v1/experiments/scheduled/{experimentId}/_cancel` |
+| 10 | `POST /v1/experiments/{experimentId}/variants` |
+| 11 | `DELETE /v1/experiments/{experimentId}/variants/{name}` |
+| 12 | `PUT /v1/experiments/{experimentId}/variants/{name}` |
+| 13 | `PUT /v1/experiments/{experimentId}/variants/{name}/_promote` |
+| 14 | `DELETE /v1/experiments/{experimentId}/targetingConditions/{id}` |
+
+Endpoints under `/v1/experiments` that do **not** carry an Experiment, and are therefore untouched:
+`DELETE /v1/experiments/{experimentId}` (returns a confirmation message), `POST /v1/experiments/isUserIncluded`,
+`GET /v1/experiments/{id}/results`, `GET /v1/experiments/health`.
+
+### Key Entities
+
+- **Experiment**: the object being serialized. Already carries `createdBy` (the creator's user ID,
+ also used as the permission owner) and `lastModifiedBy`. Gains one read-time field,
+ `createdByUserName`, that is derived from `createdBy` and not persisted.
+- **User**: the creator behind `createdBy`. Supplies the display name. May not exist any more, and
+ may have no usable name; both cases resolve to the fallback rule.
+
+---
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: A reader of an experiment listing can identify the creator of every row by name,
+ without the client making any request beyond the one that returned the list.
+- **SC-002**: 100% of the 14 endpoints listed under "Endpoints in scope" return the creator name
+ field; 0% of the endpoints outside that list change shape.
+- **SC-003**: 0 existing Experiment fields change key, value, type or meaning — verified by the
+ existing experiment test suite passing without modification.
+- **SC-004**: For a listing of 50 experiments created by a single user, the response time is
+ indistinguishable from the same listing before this change (within normal run-to-run variation),
+ and the number of user records read from the database is at most 1.
+- **SC-005**: 100% of requests for an experiment whose creator cannot be resolved still succeed and
+ report a non-empty creator name value.
+- **SC-006**: The published API documentation describes the new field, and the repository's generated
+ contract file matches what the build produces — the CI contract check passes on the first run.
+- **SC-007**: The consumer work (#37307, listing Created By column) can be built against the
+ documented contract with no further backend change.
+
+---
+
+## Assumptions
+
+- **A1 — User lookups are already cache-backed.** Loading a user by ID consults the user cache before
+ hitting the database (`UserFactoryLiferayImpl.loadUserById` reads `UserCache` first and populates
+ it on a miss). A listing whose experiments share a creator therefore costs one database read for
+ the first row and none afterwards, which is what FR-014 relies on. FR-014 still stands as a
+ requirement rather than a freebie, because the cache is not infinite and a listing spanning many
+ distinct creators must not degenerate into one lookup per row.
+
+- **A2 — The mechanism for a derived JSON field is a plan-phase decision, and it is constrained.**
+ The Experiment model is an Immutables value type serialized by Jackson. The two obvious immutables
+ mechanisms behave differently and neither is free:
+ - an eagerly-derived attribute is computed at build time, so it would fire on *every* Experiment
+ construction, including the ones that never reach a response (the database transformer, the
+ push-publish dependency walk, running-experiment selection during page rendering) — which
+ FR-015 forbids;
+ - a lazily-computed attribute is memoized inside the instance, and the running-experiments list
+ is cached whole, so a memoized name on those long-lived shared entries would outlive a rename —
+ which FR-016 forbids. (The per-experiment cache in `ExperimentsFactoryImpl.find` is commented
+ out with a TODO, so `find` and `list` build fresh instances on every call; only that list cache
+ is live.)
+
+ Neither mechanism is settable from JSON, which is where FR-023 comes from. The spec therefore
+ states the constraints (FR-014, FR-015, FR-016, FR-023) and leaves the mechanism to `/speckit-plan`,
+ which must verify its choice against all four before committing to it.
+
+- **A3 — Documenting the field needs no new endpoint annotations.** `ExperimentsResource` today
+ carries only a `@Tag`; it has no `@Operation`/`@ApiResponse` annotations at all, and the Experiment
+ schema in `openapi.yaml` is derived from the model. A schema annotation placed on the model's
+ accessor does reach the generated contract — verified against `AbstractTimestampsView`, whose
+ per-accessor descriptions and examples appear verbatim under the `TimestampsView` schema. So
+ FR-018 is satisfied by annotating the model, and adding a full Swagger annotation pass to
+ `ExperimentsResource` is **not** part of this work.
+
+- **A4 — "Full name" means the platform's existing notion of a full name**: first, middle and last
+ name joined as `User.getFullName()` already does, rather than a new formatting rule invented here.
+ That method joins the parts with spaces and never trims, so it returns a single space when every part is blank, and leaves padding like "Admin " when only one part is set, which is exactly the case FR-010
+ covers.
+
+- **A5 — The name is derived in the REST layer, not on the model.** An earlier revision put the
+ accessor on `AbstractExperiment` itself. That made every serialization of an Experiment resolve a
+ user, and serialization is not only REST: `ExperimentBundler` and `ExperimentHandler` are live —
+ they simply live under `dotCMS/src/enterprise/java` rather than `src/main/java`, which is why an
+ earlier draft of this spec wrongly called them absent. The bundler serializes the whole Experiment
+ and the handler reads it back through a mapper that rejects unknown properties, while
+ `BundlePublisher` runs every handler in one transaction — so a receiver on a build without the
+ field would have rolled back the **entire** bundle. Starter export (`ExportStarterUtil`)
+ serializes Experiments the same way. Review (#37510) asked for the derivation to move to the REST
+ layer, and it did: `ExperimentView` carries it, the model is a plain data object again, and the
+ problem is removed at the source rather than mitigated.
+
+- **A6 — The consumer needs one display string.** #37307 renders a single Created By column, so a
+ single pre-joined name is sufficient; separate first/last fields are not required.
+
+- **A7 — The issue's endpoint list is close but not exact**, and the table above supersedes it. The
+ issue lists `delete` among the endpoints that carry an Experiment; `DELETE /v1/experiments/{id}`
+ actually returns a confirmation message and no experiment. The issue also omits two endpoints that
+ *do* return an Experiment: `DELETE /v1/experiments/{experimentId}/goals/primary` and
+ `DELETE /v1/experiments/{experimentId}/targetingConditions/{id}`. Both differences are corrections
+ to the issue text only — they do not change the approach, because the field is added once at the
+ Experiment level and every experiment-carrying response inherits it.
+
+- **A8 — Backend only.** No frontend work belongs to this issue; the portlet column is #37307.
+
+---
+
+## Out of Scope
+
+- **`lastModifiedBy`.** It has the same ID-not-name problem and is deliberately excluded. If the
+ portlet later surfaces a "last modified by" column, it can be added with the same mechanism as a
+ follow-up issue.
+- **The portlet's Created By column itself** (#37307) — this work only supplies the data.
+- **Storing the creator's name.** No new column, no migration, no denormalized copy (FR-016, FR-017).
+- **Paging, sorting, counting or permission filtering on the experiment list.** Known gaps in the
+ list endpoint, tracked separately; this change must not attempt them and must not make them harder.
+- **Names for any other user reference** in the experiment payload or elsewhere in the API.
+- **A Swagger annotation pass over `ExperimentsResource`.** Only the new field is documented (A3).
+
+---
+
+## Dependencies
+
+- **Consumer**: [#37307](https://github.com/dotCMS/core/issues/37307) — the Experiments portlet
+ listing Created By column — is blocked on this field and must not merge before it.
+- **Epic**: [#36763](https://github.com/dotCMS/core/issues/36763) — Experiments: A/B Testing v2.