Skip to content

Upgrading

Upgrading from 3.x to 4.0

This guide walks every breaking change between Wheels 3.x and 4.0, with before/after code for each. Most 3.x apps upgrade in an afternoon. The pressure points are CORS defaults, the test base class rename, the plugins/ → packages split (packages are now standalone repos installed into vendor/), the wheels CLI command renames, and Vite manifest strictness (production now throws on missing entries — rebuild assets during the upgrade window). Everything else is either additive or continues to work with a deprecation warning.

You’ll learn:

  • Every breaking default and rename in 4.0, with the source PR or CHANGELOG.md entry
  • How to port a 3.x plugin to the new package activation model
  • How to move your tests from wheels.Test (RocketUnit) to wheels.WheelsTest (BDD)
  • What to verify after the upgrade and where to look when something misbehaves
  1. Pin your CFML engine versions in .cfconfig.json or your container image. 4.0 supports Lucee 5/6/7, Adobe ColdFusion 2018/2021/2023/2025, and BoxLang — the reference platform is Lucee 7 + SQLite.

  2. Take a database snapshot. The migrator’s migrateTo() behavior was tightened in 4.0 to detect previously-skipped migrations in a range (CHANGELOG.md → Fixed, #1928).

  3. Confirm your 3.x test suite is green. If it isn’t, upgrading will mix real breakage with pre-existing failures.

  4. Read the audit and comparison docs for context: docs/releases/wheels-3.0-vs-4.0.md and docs/releases/wheels-4.0-audit.md.

  5. Replace vendor/wheels/ with the 4.0 source. If you vendor the framework, git checkout the 4.0 tag. If you clone-and-run, pull.

Each item cites its CHANGELOG.md “Changed” or “Removed” entry. Where the skeleton blog post calls out a two-path upgrade, Path A (fix directly) is documented here; Path B (the Legacy Compatibility Adapter, #2015) is covered at the end.

1. CORS default changed from wildcard to deny-all

Section titled “1. CORS default changed from wildcard to deny-all”

CHANGELOG: Breaking: CORS middleware default changed from wildcard * to deny-all (#2039).

In 3.x, the CORS middleware defaulted to allowOrigins="*". In 4.0, there is no default — you must configure allowOrigins explicitly or requests are rejected.

config/settings.cfm
// 3.x — no configuration needed; wildcard was the default
set(middleware = [ new wheels.middleware.Cors() ]);
config/settings.cfm
// 4.0 — allowOrigins is required
set(middleware = [
new wheels.middleware.Cors(allowOrigins="https://myapp.com,https://admin.myapp.com")
]);

Related hardening: wildcard-plus-credentials combinations are now rejected (#2053).

The change above only affects apps that configured CORS via new wheels.middleware.Cors(). If your app used the global setting instead, none of that applies — the setting is still honored in 4.0, and no migration is required.

config/settings.cfm
// 3.x global setting — still honored in 4.0, no changes needed
set(allowCorsRequests = true);
set(accessControlAllowOrigin = "https://myapp.com");
set(accessControlAllowMethods = "GET, POST, PUT, DELETE, OPTIONS");
set(accessControlAllowHeaders = "Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With");
set(accessControlAllowCredentials = false);
set(accessControlAllowMethodsByRoute = false);

All six CORS settings (allowCorsRequests and the five accessControlAllow* settings) are initialized in the 4.0 security defaults and read on every request. The only thing that changed is the middleware’s default — not the global setting.

How the two paths differ

Global setting (set(allowCorsRequests=true))wheels.middleware.Cors
Where it runsonRequestStart event — before dispatchMiddleware pipeline — during dispatch
OPTIONS preflightHandles via abort in onRequestStartReturns empty 200 and short-circuits dispatch
Per-route scopingNoYes
Access-Control-Max-AgeNoYes
Available sinceWheels 2.xWheels 3.0

What happens when both are active

The two paths used to be independent and additive — both layers emitted their own Access-Control-Allow-* lines (cfheader stacks rather than replaces), and an origin allowed by both layers — exactly the origins a migrating app cares about — received duplicate Access-Control-Allow-Origin headers, which the Fetch spec makes browsers reject (#3114). curl looked fine; browsers failed the CORS check.

As of the #3114 fix, Wheels arbitrates this automatically. When a wheels.middleware.Cors instance is registered in the pipeline, the global onRequestStart path detects it and steps aside — skipping both $setCORSHeaders and the global OPTIONS abort — so the middleware is the single source of truth for CORS headers and preflight on every request type. A one-time warning is written to wheels.log prompting you to clean up the now-redundant setting.

One caveat: the middleware pipeline does not run on the public root/congrats page (a bare .root(method="get") route). Under the old additive behavior that page carried the global headers; now that the global path defers whenever the middleware is registered, it gets no CORS headers at all. If you need CORS on a route the pipeline doesn’t cover, use the global setting alone (without the middleware).

Even with the arbitration in place, do not run both deliberately — it is a safety net for the migration window, not a supported configuration. The moment you configure the Cors middleware, remove set(allowCorsRequests=true) (and the accessControlAllow* settings) in the same change.

Recommendation

  • Apps using the global setting: leave it. It works in 4.0 without changes.
  • Apps using the middleware: add allowOrigins explicitly (see the breaking change above) and make sure the global setting is off.
  • New CORS configuration: prefer the middleware — it supports per-route scoping and has a secure deny-all default.

Migrating from global settings to the middleware

Section titled “Migrating from global settings to the middleware”

If you follow the recommendation above and move from set(accessControlAllow*) global settings to the wheels.middleware.Cors constructor, be aware that the allow-list defaults are not a like-for-like match.

ValueLegacy global setting defaultCors constructor default
Methods (accessControlAllowMethods / allowMethods)"GET, POST, PATCH, PUT, DELETE, OPTIONS""GET,POST,PUT,PATCH,DELETE,OPTIONS"
Headers (accessControlAllowHeaders / allowHeaders)"Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With""Content-Type,Authorization,X-Requested-With"

The middleware default drops X-Auth-Token, X-Requested-By, and Origin from the header allow-list. A like-for-like swap silently shrinks the list: preflight OPTIONS requests from clients that send those headers receive an Access-Control-Allow-Headers response that omits those headers, and the browser blocks the real request with no entry in your server logs.

Pass the missing headers explicitly when constructing the middleware:

config/settings.cfm
set(middleware = [
new wheels.middleware.Cors(
allowOrigins = "https://myapp.com",
allowHeaders = "Content-Type,Authorization,X-Requested-With,X-Auth-Token,X-Requested-By,Origin"
)
]);

The methods difference (spaces vs no spaces between list items) is cosmetic — HTTP implementations trim list values. No action is needed unless you pattern-match the exact string.

CHANGELOG: Breaking: HSTS header defaults on in production (#2081).

The SecurityHeaders middleware (#2036) emits Strict-Transport-Security: max-age=31536000; includeSubDomains by default in the production environment. To suppress HSTS emission from the app (e.g., when your load balancer already sets it), pass hsts=false to the middleware constructor (#2174). deployment/security-hardening.mdx covers the full set of configuration knobs.

3. CSRF key required in production; JWT algorithm validated

Section titled “3. CSRF key required in production; JWT algorithm validated”

CHANGELOG: Breaking: Reload password must be non-empty for environment switching in production (#2082) and the Security section item for CSRF key enforced in production (#2079).

This item applies only when you store CSRF tokens in cookies (set(csrfStore="cookie")) — the default store is session, which needs no key. With the cookie store, an empty key is auto-generated outside production (#2054) with a wheels_security warning, but cookies rotate on every deploy when that happens; in production the framework throws Wheels.Security.MissingCsrfKey instead of auto-generating. Set a stable key.

The setting name is csrfCookieEncryptionSecretKey — that is the only name the framework reads:

config/settings.cfm
set(csrfCookieEncryptionSecretKey = env("WHEELS_CSRF_KEY"));

JWT verification now validates the alg claim and uses constant-time signature comparison (#2079, #2086). Tokens forged with alg: none or mismatched algorithms are rejected.

4. allowEnvironmentSwitchViaUrl defaults to false in production

Section titled “4. allowEnvironmentSwitchViaUrl defaults to false in production”

CHANGELOG: Breaking: allowEnvironmentSwitchViaUrl defaults to false in production (#2076) and the reload-password requirement (#2082).

In 3.x, ?environment=production on a URL could switch the running environment. In 4.0, that is off by default in production and the reload password must be non-empty. Re-enable only for controlled staging environments.

The reload password is populated only by set(reloadPassword = ...) in config/settings.cfm. A value in .env alone is not wired into framework settings automatically — you must call set() explicitly:

config/settings.cfm
// env() reads from .env (or OS environment); the empty-string fallback
// preserves the fail-closed behavior when the variable is absent.
set(reloadPassword = env("WHEELS_RELOAD_PASSWORD", ""));

Without this line, every boot will log the following warning even if reloadPassword is present in .env:

WARN Wheels: reloadPassword is empty — URL-based environment switching and application reload are disabled until a password is set in config/settings.cfm

The env() helper is the canonical pattern for flowing .env values into framework settings. Use the same approach for csrfCookieEncryptionSecretKey and other security settings.

config/environment.cfm has the same load-order gap

Section titled “config/environment.cfm has the same load-order gap”

wheels new scaffolds a hardcoded set(environment="development") in config/environment.cfm, so this gap does not affect freshly generated apps. If you wired the running environment from .env by hand — for example, set(environment=application.env.environment) — the same load-order issue applies:

config/environment.cfm — unreliable pattern
// application.env may not be populated before this file runs.
set(environment=application.env.environment);

When application.env is not yet initialized, the expression resolves to "". The set() call either errors or silently leaves the environment at the wrong value. In production this commonly manifests as servers reporting environment=development to Sentry, the debug bar, and anywhere get("environment") is consumed — despite .env containing environment=production.

Use env() directly:

config/environment.cfm — correct pattern
// env() reads from .env (or OS environment) without going through application.env.
// Default "production" is intentional: a missing key fails safe to prod, not dev.
set(environment=env("environment", "production"));

The "production" default is deliberate: a misconfigured or missing environment key in .env resolves to the safer environment rather than development, where debug output, verbose errors, and a lower security posture would be active on a live server.

CHANGELOG: Breaking: RateLimiter trustProxy default changed from true to false (#2024); Breaking: RateLimiter proxy strategy default changed to last (#2088).

If your app sits behind a proxy or load balancer, configure both flags explicitly — do not rely on the 3.x dev defaults.

config/settings.cfm
set(middleware = [
new wheels.middleware.RateLimiter(
maxRequests = 100,
windowSeconds = 60,
trustProxy = true,
proxyStrategy = "last"
)
]);

Rate limiter also now fails closed on lock timeout (#2069) rather than fail-open.

CHANGELOG: Breaking: CSRF cookie now sets SameSite attribute (#2035).

Cross-site POSTs from third-party frames that relied on the missing attribute will break. Same-site app flows are unaffected.

7. wheels snippets renamed to wheels generate snippets

Section titled “7. wheels snippets renamed to wheels generate snippets”

CHANGELOG: Breaking: wheels snippets CLI command renamed to wheels generate snippets (#1852).

Update any scripts, CI jobs, or IDE integrations that shell out to wheels snippets. The flag surface is otherwise unchanged.

8. Test base class renamed: wheels.Testwheels.WheelsTest

Section titled “8. Test base class renamed: wheels.Test → wheels.WheelsTest”

CHANGELOG: Breaking: Test base class namespace renamed (#1889) and Deprecated: wheels.Test test base class (#1889).

The RocketUnit-era base (wheels.Test) still loads during 4.0, but all new tests extend wheels.WheelsTest and use BDD syntax.

tests/specs/models/UserSpec.cfc — 3.x style (still loads, legacy only)
component extends="wheels.Test" {
function test_user_requires_email() {
var u = model("User").new();
assert("NOT u.valid()");
}
}
tests/specs/models/UserSpec.cfc — 4.0 BDD style
component extends="wheels.WheelsTest" {
function run() {
describe("User", () => {
it("requires an email", () => {
var u = model("User").new();
expect(u.valid()).toBeFalse();
});
});
}
}

See the Testing guide for matchers and the phantom-matcher list.

There is also a wheels.Testbox shim — a pure alias of wheels.WheelsTest retained for specs written before the rename. It adds no behavior. Deprecated since 4.0 with a removal target of 5.0; both it and wheels.Test should migrate to wheels.WheelsTest. Note: the released 4.0.3 wheels upgrade check only flags extends="wheels.Test"; the combined rule that also flags extends="wheels.Testbox" lives on develop and ships in a post-4.0.3 release.

9. Tests directory renamed: tests/specs/functions/tests/specs/functional/

Section titled “9. Tests directory renamed: tests/specs/functions/ → tests/specs/functional/”

CHANGELOG: Breaking: Tests directory tests/specs/functions/ renamed to tests/specs/functional/ (#1872).

Rename the directory. No code changes required.

10. application.wirebox renamed to application.wheelsdi

Section titled “10. application.wirebox renamed to application.wheelsdi”

CHANGELOG: Breaking: application.wirebox renamed to application.wheelsdi (#1888).

The DI container moved in-house (CHANGELOG.mdInternal rim modernized: WireBox/TestBox replaced, #1883). The surface is compatible, but code that reached into application.wirebox directly must be updated.

app/lib/Something.cfc
// 3.x
var svc = application.wirebox.getInstance("emailService");
// 4.0
var svc = application.wheelsdi.getInstance("emailService");
// 4.0, preferred
var svc = service("emailService");

service() is the new global helper (#1933); prefer it over reaching into the container.

11. Vite manifest strictness — missing entries throw in production

Section titled “11. Vite manifest strictness — missing entries throw in production”

CHANGELOG: Breaking: viteStrictManifest defaults to true (#2133).

In 3.x, a missing entry in public/assets/.vite/manifest.json silently fell back to the raw source path. In 4.0, the default flips: viteScriptTag(), viteStyleTag(), and vitePreloadTag() throw Wheels.ViteAssetNotFound in production when the manifest doesn’t contain the requested entrypoint. This catches stale-build deploys at first request instead of letting them ship broken <script> tags to the browser.

The most common failure mode is a deploy that pushes new CFML code without rebuilding the Vite bundle — the manifest on the server is older than the entrypoint referenced in a view.

Recommended fix — rebuild assets as part of the deploy:

your shell (or CI/CD deploy step)
npm run build # produces public/assets/.vite/manifest.json

Escape hatch — restore 3.x silent-fallback behavior:

config/settings.cfm
set(viteStrictManifest = false);

Use the escape hatch only if you can’t rebuild assets during the upgrade window — then flip it back on once your deploy pipeline emits the manifest reliably.

CHANGELOG: Deprecated: Legacy plugins/ folder — superseded by the new packages/ → vendor/ activation model. Plugins still load, with a deprecation warning. (#1995).

The plugins/ directory still works. It emits a warning at load. Plan a migration — here’s the shape of porting a 3.x plugin called AuditLog into its own package repo:

  1. Create a package repo. The main CFC’s filename matches the directory name. You can start locally and publish to GitHub later (or host privately).

    package layout
    wheels-auditlog/
    package.json
    AuditLog.cfc
  2. Write the package.json manifest. provides.mixins is opt-in; 3.x plugins defaulted to global, which is why provenance was hard to trace.

    wheels-auditlog/package.json
    {
    "name": "wheels-auditlog",
    "version": "1.0.0",
    "description": "Writes a row to audit_log for every mutating action",
    "wheelsVersion": ">=4.0",
    "provides": {
    "mixins": "controller"
    }
    }
  3. Port the plugin CFC. Rename lifecycle hooks if you used them — init, register, boot, onPluginLoad, onPluginActivate are reserved and never mixed in.

    wheels-auditlog/AuditLog.cfc
    component output="false" {
    public any function init() {
    return this;
    }
    public void function recordAudit(required string action, required numeric recordId) {
    model("AuditEntry").create(
    action = arguments.action,
    recordId = arguments.recordId,
    userId = session.userId ?: 0
    );
    }
    }
  4. Install into vendor/, then reload:

    your shell
    wheels packages add wheels-auditlog
    wheels reload
  5. Delete plugins/auditlog/. The deprecation warning goes away.

See Packages for manifest fields, per-method mixin overrides via mixin="model" or mixin="none" annotations, and error isolation. If you need a staged migration, the wheels-legacy-adapter package ships deprecation logging and API shims for 3.x patterns.

CHANGELOG: composable pagination helpers added (#1930). The 3.x paginationLinks() is retained but deprecated. As of #2727, calling paginationLinks() emits a one-time per-request WriteLog(type="warning") pointing at the replacement so apps see the signal without flooding the log. wheels upgrade check --to=4.0.0 also greps app/views/ for paginationLinks( and flags every hit with a remediation pointer. New code should use paginationNav() or compose the individual helpers (paginationInfo, firstPageLink, previousPageLink, pageNumberLinks, nextPageLink, lastPageLink).

CHANGELOG: Deprecated: RocketUnit test style for new tests (#1925) and Removed: Legacy RocketUnit core test scaffolding (#1925). Existing app specs still run; the framework-level RocketUnit runner is gone. Do not write new tests in RocketUnit style.

CHANGELOG: Deprecated: In-dev-server HTTP MCP endpoint at /wheels/mcp — superseded by the LuCLI stdio MCP server (wheels mcp wheels).

There is no wheels mcp setup command — migrate by adding the stdio server to your .mcp.json manually:

.mcp.json
{"mcpServers": {"wheels": {"command": "wheels", "args": ["mcp", "wheels"]}}}

Note that bare wheels mcp does not print this snippet on the released launcher (it errors with “missing module name”); see the MCP integration guide for editor-specific variants.

CHANGELOG: Deprecated: Legacy CommandBox wheels-cli module — superseded by the new wheels CLI. Scheduled for removal in v5.0. (#2227, #2634)

The CommandBox-based wheels-cli module (invoked as box wheels upgrade, box wheels generate, etc.) is deprecated and does not know about Wheels 4.0+. Running box wheels upgrade now prints a deprecation banner and exits immediately instead of silently reporting “You are already on the latest version.” Use the new Wheels CLI for upgrade checks:

your shell
brew install wheels-dev/wheels/wheels
wheels upgrade check # scan for breaking changes
wheels upgrade apply # swap vendor/wheels/ from the CLI bundle (creates backup)

Homebrew 5.1+ asks you to trust third-party taps on first use — run brew trust wheels-dev/wheels once if prompted.

Browser-test fixture routes (/_browser/*) moved into the framework

Section titled “Browser-test fixture routes (/_browser/*) moved into the framework”

CHANGELOG: Fixed: Framework-internal browser-test fixture controllers, views, and /_browser/* routes no longer leak into application-level files. (#2135, #2138)

The /_browser/home, /_browser/login, /_browser/dashboard, /_browser/login-as, and related fixture routes used by Wheels’ browser-testing DSL (wheels.wheelstest.BrowserTest) used to live in application-level files — config/routes.cfm plus app/controllers/BrowserTest*.cfc and app/views/browsertest*/. As of 4.0 they ship as framework internals under vendor/wheels/public/browser-fixtures/ and are auto-mounted by the framework’s own route loader.

What you need to do depends on whether your app relied on these routes:

  • You never used /_browser/* yourself — no action. These routes were only ever meaningful when you ran Wheels’ own browser specs against the dev server, and Wheels-generated app scaffolds (wheels new) never included them. Your config/routes.cfm stays as-is.
  • You ran Wheels browser specs against a running dev server — opt in by adding set(loadBrowserTestFixtures=true); to config/settings.cfm (or to your testing/development env override). The framework then mounts the /_browser/* routes automatically. The setting defaults to false and production/maintenance/staging environments always skip the mount.
  • You defined your own /_browser/* routes in config/routes.cfm (rare — Wheels 4.0 snapshot only) — with the fixtures enabled, your copies are dead code: the framework registers the fixture routes before config/routes.cfm is loaded, and Wheels routing is first-registration-wins, so a same-pattern app route can not override a fixture. Delete your copies. If what you actually need is a custom login flow for browser specs, use set(browserLoginAsHandler="YourController##yourAction") instead of shadowing /_browser/login-as.

CHANGELOG → Removed:

  • Legacy RocketUnit core test scaffolding (#1925) — framework-level runner only; app specs still load.
  • Railo compatibility workaround in $initializeMixins (#1987) — Railo is no longer a target.
  • server.cfc file (#1902).
  • cli/lucli/services/MCP.cfc parallel schema registry — never wired into LuCLI’s MCP discovery; rich parameter schemas return via typed parameters on Module.cfc in a follow-up.

The Legacy Compatibility Adapter (optional soft landing)

Section titled “The Legacy Compatibility Adapter (optional soft landing)”

CHANGELOG: Legacy compatibility adapter for 3.x → 4.0 migration soft-landing (#2015), now distributed as the standalone wheels-dev/wheels-legacy-adapter package.

When you can’t fix all eleven breaking items in one sprint, install the adapter, ship the upgrade, and modernize incrementally:

your shell
wheels packages add wheels-legacy-adapter
wheels reload

The adapter logs every deprecated call so you can track what’s still 3.x-shaped in production.

The adapter covers these 3.x patterns; everything else still requires manual remediation:

Covered by the adapterRequires manual remediation regardless
renderPage()renderView() shimItems 1–9 and 11 (all other breaking changes)
renderPageToString() shimapplication.wireboxapplication.wheelsdi (item 10)
$legacySendEmail() argument remapnew wirebox.system.ioc.Injector(...) bootstrap code
$legacyAppScopeGet(key) opt-in helper
Migration scanner
Deprecation call logger

These are additive in 4.0 and worth adopting during the upgrade window:

  • Middleware pipeline (#1924) for cross-cutting concerns previously done with filters or plugins.
  • Route model binding (#1929) — binding=true on resource routes auto-resolves params.user, cutting boilerplate findByKey() in every show/edit/update.
  • Chainable QueryBuilder (#1922) everywhere you concatenated WHERE strings.
  • Built-in job worker (#1934) to replace external Redis-backed queues.
  • HTTP test client (TestClient, #2099) for integration tests.
  • Parallel test runner (#2100) for faster CI.
  1. Run the test suite.

    your shell
    wheels test
  2. Run wheels doctor to surface config and environment issues.

    your shell
    wheels doctor
  3. Smoke-test a form submission end-to-end. CSRF and SameSite cookie tightening can break third-party embeds silently.

  4. Check the application log on first request after deploy. PackageLoader reports every package it loaded, every package it skipped, and why. If you ported a plugin, confirm the old entry is gone and the new one loaded.

  5. If you configured HSTS or CORS in code, verify the response headers with curl -I. Misconfigured allowOrigins returns no CORS header at all — the browser reports it as a generic CORS failure.

  • “CSRF token invalid” on forms after upgrade. Either the cookie encryption key rotated (cookie store only — set csrfCookieEncryptionSecretKey explicitly) or a third-party embed is hitting SameSite. See #2035, #2054, #3115.
  • Requests return 403 with no CORS header. allowOrigins isn’t set. See #2039.
  • CORS preflight rejected after switching from global settings to the middleware. The Cors constructor’s allowHeaders default (Content-Type,Authorization,X-Requested-With) does not include X-Auth-Token, X-Requested-By, or Origin — all present in the legacy accessControlAllowHeaders global default. Pass the missing headers explicitly. See Migrating from global settings to the middleware.
  • Rate limiter counts wrong behind your load balancer. trustProxy defaults to false now. See #2024, #2088.
  • Test runner can’t find specs. Check for tests/specs/functions/ — rename to functional/. See #1872.
  • Plugin warning at startup. Port to a package (see above) or accept the warning until you do.
  • JWT tokens reject as invalid after upgrade. Algorithm validation is on. Confirm your issuer sets alg to a supported value and the signature verification path uses constant-time comparison. See #2079, #2086.
  • Wheels.ViteAssetNotFound thrown on first request after deploy. The deploy shipped code referencing a Vite entrypoint that isn’t in the manifest. Rebuild Vite assets (npm run build) before deploy, or temporarily set(viteStrictManifest=false) to restore 3.x silent fallback. See #2133.
  • Boot fails with a “wirebox” package-not-found error. The wirebox package was removed from vendor/wheels/ (#1883). Installing the legacy adapter does not restore it. Replace new wirebox.system.ioc.Injector(...) in Application.cfc with new wheels.Injector(), and replace any application.wirebox references with application.wheelsdi. See item 10.
  • “reloadPassword is empty” boot warning despite having reloadPassword in .env. The reload password is populated only by set(reloadPassword = ...) in config/settings.cfm — a .env value does not flow through automatically. Add set(reloadPassword = env("WHEELS_RELOAD_PASSWORD", "")); to config/settings.cfm. See #2631.
  • Production servers report environment=development despite .env containing environment=production. application.env.environment is not reliably populated before config/environment.cfm runs — the load-order gap means the expression resolves to "" and set() picks up the wrong value. Replace set(environment=application.env.environment) with set(environment=env("environment", "production")). The "production" default ensures a missing key fails safe to production rather than development. See #2709.
  • Static assets under /miscellaneous/, /javascripts/, /stylesheets/, or /files/ return 404 after upgrade. LuCLI’s bundled-default rewrite.config uses a narrow allow-list (images|css|js|fonts|assets|static) that excludes 3.x-conventional directory names. wheels start now auto-provisions a project-level rewrite.config that covers these dirs on the first boot. If you upgraded before this fix, run wheels start once against your upgraded project — the file is dropped automatically and existing user-authored rewrite.config files are left untouched. See #2626.