diff --git a/src/org/labkey/test/tests/upgrade/BaseUpgradeTest.java b/src/org/labkey/test/tests/upgrade/BaseUpgradeTest.java
index 5faa109585..97f80366ba 100644
--- a/src/org/labkey/test/tests/upgrade/BaseUpgradeTest.java
+++ b/src/org/labkey/test/tests/upgrade/BaseUpgradeTest.java
@@ -45,11 +45,29 @@
* not relevant to the version of LabKey being upgraded from (specified in the {@code webtest.upgradePreviousVersion}
* system property).
* The setup steps will be skipped if the {@code webtest.upgradeSetup} system property is set to {@code false}.
+ *
Writing a new upgrade test
+ *
+ * - Use a package name containing {@code upgrade}. Do all setup in {@link #doSetup()} rather than a
+ * {@code @BeforeClass}, and prefer APIs to the UI. {@code @Test} methods must be read-only or re-runnable, since
+ * cleanup is skipped after the upgrade.
+ * - The setup phase runs the older branch's copy of the test, so a test that exists only on the newer
+ * branch never gets setup and anything touching its project or users fails. Commit a matching copy to a feature
+ * branch on the preceding ESR release ({@code fb_coolUpgrade} plus {@code 26.3_fb_coolUpgrade}); TeamCity pairs
+ * them. Both copies do the full setup, allowing for API changes between the releases. The older one can
+ * do minimal validation and just needs one {@code @Test}. Names the newer copy looks up, such as the project and test
+ * users, must match exactly.
+ * - Guard methods that depend on setup data with {@link EarliestVersion} naming the earliest release that
+ * carries a copy of the test, which is not necessarily the release the feature shipped in.
+ * - One leg of the pipeline validates a build against itself, where nothing changed and {@link #setupVersion} is
+ * the running version. {@link #wasSetupBefore(String)} and {@link #wasSetupWithin(String, String)} adjust
+ * expectations for that leg; the annotations cannot express it.
+ *
*/
public abstract class BaseUpgradeTest extends BaseWebDriverTest
{
protected static final boolean isUpgradeSetupPhase = TestProperties.getBooleanProperty("webtest.upgradeSetup", true);
+ /** The version the setup phase ran, from {@code webtest.upgradePreviousVersion}; the running version if unset. */
protected static final Version setupVersion = isUpgradeSetupPhase ? TestProperties.getProductVersion() :
Optional.ofNullable(trimToNull(System.getProperty("webtest.upgradePreviousVersion"))).map(Version::new)
.orElse(TestProperties.getProductVersion());
diff --git a/src/org/labkey/test/tests/upgrade/SystemUpgradeAuditTest.java b/src/org/labkey/test/tests/upgrade/SystemUpgradeAuditTest.java
new file mode 100644
index 0000000000..a80f7965c6
--- /dev/null
+++ b/src/org/labkey/test/tests/upgrade/SystemUpgradeAuditTest.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.labkey.test.tests.upgrade;
+
+import org.junit.Assume;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.labkey.remoteapi.CommandException;
+import org.labkey.remoteapi.Connection;
+import org.labkey.remoteapi.query.ContainerFilter;
+import org.labkey.remoteapi.query.SelectRowsCommand;
+import org.labkey.remoteapi.query.SelectRowsResponse;
+import org.labkey.remoteapi.query.Sort;
+import org.labkey.test.util.ApiPermissionsHelper;
+import org.labkey.test.util.AuditLogHelper;
+import org.labkey.test.util.DataRegionTable;
+import org.labkey.test.util.PermissionsHelper;
+import org.labkey.test.util.TestUser;
+import org.labkey.test.util.Version;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Covers the audit event recorded by SystemUpgradeAuditProvider when the server comes up on a new version or build.
+ * Most assertions hold on any single boot; testVersionChangeRecorded is the reason this is an upgrade test, since a
+ * recorded version change only exists once a server has booted twice on two different versions.
+ * Reads from /home and builds what it needs inside each test, so it needs no setup phase - and therefore no matching
+ * copy on the preceding ESR branch, which the setup phase would otherwise run.
+ */
+@Category({})
+public class SystemUpgradeAuditTest extends BaseUpgradeTest
+{
+ private static final String AUDIT_QUERY = "SystemUpgradeAuditEvent";
+ private static final String AUDIT_LOG_LABEL = "System Upgrade Events";
+
+ private static final String PROJECT_ADMIN_EMAIL = "project_admin@systemupgradeaudit.test";
+
+ /** First release that records SystemUpgradeAuditEvent, so the earliest one that can be a recorded previous version. */
+ private static final String FIRST_AUDITED_RELEASE = "26.9";
+
+ /** Every server has one, so no test-owned project is needed to query with a project-scoped container filter. */
+ private static final String HOME_PROJECT = "/home";
+
+ private static final List AUDIT_COLUMNS = List.of(
+ "RowId",
+ "Created",
+ "ChangeType",
+ "ReleaseVersion",
+ "PreviousReleaseVersion",
+ "BuildTime",
+ "PreviousBuildTime",
+ "HasSchemaUpgrade",
+ "Comment"
+ );
+
+ /** The event is written at startup and each test creates what it needs, so the setup phase has nothing to do. */
+ @Override
+ protected void doSetup()
+ {
+ }
+
+ /** This test owns no project, so there is nothing for the framework to clean up. */
+ @Override
+ protected String getProjectName()
+ {
+ return null;
+ }
+
+ @Override
+ protected BrowserType bestBrowser()
+ {
+ return BrowserType.CHROME;
+ }
+
+ /** Every server has run at least one boot with this feature, so a baseline event must exist for the running build. */
+ @Test
+ public void testBaselineEventRecorded() throws Exception
+ {
+ List