diff --git a/changelog.d/reload-refused-dev-notice.added.md b/changelog.d/reload-refused-dev-notice.added.md
new file mode 100644
index 0000000000..83b26a3b12
--- /dev/null
+++ b/changelog.d/reload-refused-dev-notice.added.md
@@ -0,0 +1 @@
+- In the development environment, a refused `?reload=true` is no longer a silent no-op: the debug bar now renders an inline notice explaining why the reload did not fire and what to do. Three cases are distinguished — `reloadPassword` is empty (URL reload disabled, fail-closed since 4.0.4, with the `set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', ''))` fix inline), the `password` URL parameter is missing, and a deliberately generic "refused" for wrong-password or rate-limited attempts (pointing at `wheels_security.log` without distinguishing the two, so the notice adds no oracle on top of the constant-time compare). The reload gate in all four `public/Application.cfc` template copies records the refusal reason in `request.wheels.reloadRefusedReason` (pinned by a structural parity spec); message text and the development-only gate live framework-side in the debug bar. Other environments are unchanged: silent no-op plus `wheels_security.log`, exactly as before ([#3311](https://github.com/wheels-dev/wheels/issues/3311))
diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc
index 21f1dbb142..93a37230f8 100644
--- a/cli/lucli/templates/app/public/Application.cfc
+++ b/cli/lucli/templates/app/public/Application.cfc
@@ -330,6 +330,25 @@ component output="false" {
// Fail silently if logging fails
}
}
+ // Record WHY a requested reload did not fire so the framework's debug
+ // bar can render a development-only notice instead of a silent no-op
+ // (issue #3311). Recording is environment-agnostic — a request-scope
+ // flag, no output; the message text and the development-environment
+ // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
+ // so wording can improve without template drift. Wrong-password and
+ // rate-limited attempts deliberately collapse into one generic reason
+ // so the notice adds no oracle on top of $secureCompare().
+ if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
+ local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
+ && Len(application.wheels.reloadPassword);
+ if (!local.reloadPasswordConfigured) {
+ request.wheels.reloadRefusedReason = "emptyPassword";
+ } else if (!StructKeyExists(url, "password")) {
+ request.wheels.reloadRefusedReason = "missingPasswordParam";
+ } else {
+ request.wheels.reloadRefusedReason = "refused";
+ }
+ }
}
if (local.reloadAuthorized) {
application.wo.$debugPoint("total,reload");
diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc
index 76bc8c6639..b945e0deb0 100644
--- a/examples/starter-app/public/Application.cfc
+++ b/examples/starter-app/public/Application.cfc
@@ -303,6 +303,25 @@ component output="false" {
// Fail silently if logging fails
}
}
+ // Record WHY a requested reload did not fire so the framework's debug
+ // bar can render a development-only notice instead of a silent no-op
+ // (issue #3311). Recording is environment-agnostic — a request-scope
+ // flag, no output; the message text and the development-environment
+ // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
+ // so wording can improve without template drift. Wrong-password and
+ // rate-limited attempts deliberately collapse into one generic reason
+ // so the notice adds no oracle on top of $secureCompare().
+ if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
+ local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
+ && Len(application.wheels.reloadPassword);
+ if (!local.reloadPasswordConfigured) {
+ request.wheels.reloadRefusedReason = "emptyPassword";
+ } else if (!StructKeyExists(url, "password")) {
+ request.wheels.reloadRefusedReason = "missingPasswordParam";
+ } else {
+ request.wheels.reloadRefusedReason = "refused";
+ }
+ }
}
if (local.reloadAuthorized) {
application.wo.$debugPoint("total,reload");
diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc
index 76bc8c6639..b945e0deb0 100755
--- a/examples/tweet/public/Application.cfc
+++ b/examples/tweet/public/Application.cfc
@@ -303,6 +303,25 @@ component output="false" {
// Fail silently if logging fails
}
}
+ // Record WHY a requested reload did not fire so the framework's debug
+ // bar can render a development-only notice instead of a silent no-op
+ // (issue #3311). Recording is environment-agnostic — a request-scope
+ // flag, no output; the message text and the development-environment
+ // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
+ // so wording can improve without template drift. Wrong-password and
+ // rate-limited attempts deliberately collapse into one generic reason
+ // so the notice adds no oracle on top of $secureCompare().
+ if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
+ local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
+ && Len(application.wheels.reloadPassword);
+ if (!local.reloadPasswordConfigured) {
+ request.wheels.reloadRefusedReason = "emptyPassword";
+ } else if (!StructKeyExists(url, "password")) {
+ request.wheels.reloadRefusedReason = "missingPasswordParam";
+ } else {
+ request.wheels.reloadRefusedReason = "refused";
+ }
+ }
}
if (local.reloadAuthorized) {
application.wo.$debugPoint("total,reload");
diff --git a/public/Application.cfc b/public/Application.cfc
index af1b2c610f..9fd51547d5 100644
--- a/public/Application.cfc
+++ b/public/Application.cfc
@@ -331,6 +331,25 @@ component output="false" {
// Fail silently if logging fails
}
}
+ // Record WHY a requested reload did not fire so the framework's debug
+ // bar can render a development-only notice instead of a silent no-op
+ // (issue #3311). Recording is environment-agnostic — a request-scope
+ // flag, no output; the message text and the development-environment
+ // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
+ // so wording can improve without template drift. Wrong-password and
+ // rate-limited attempts deliberately collapse into one generic reason
+ // so the notice adds no oracle on top of $secureCompare().
+ if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
+ local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
+ && Len(application.wheels.reloadPassword);
+ if (!local.reloadPasswordConfigured) {
+ request.wheels.reloadRefusedReason = "emptyPassword";
+ } else if (!StructKeyExists(url, "password")) {
+ request.wheels.reloadRefusedReason = "missingPasswordParam";
+ } else {
+ request.wheels.reloadRefusedReason = "refused";
+ }
+ }
}
if (local.reloadAuthorized) {
application.wo.$debugPoint("total,reload");
diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm
index d08fab33e6..ad7b2c944f 100644
--- a/vendor/wheels/events/onrequestend/debug.cfm
+++ b/vendor/wheels/events/onrequestend/debug.cfm
@@ -87,6 +87,28 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
+
+
+
+ Reload not performed.
+
+ URL-based reload is disabled because reloadPassword is empty (fail-closed since 4.0.4).
+ Set set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', '')) in config/settings.cfm,
+ put the value in .env, then reload with ?reload=true&password=...
+
+ A reloadPassword is configured but the request carried no password parameter.
+ Append &password=<your reloadPassword> to the URL.
+
+ The reload request was refused. Check wheels_security.log for details.
+
+
+
+
diff --git a/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc b/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc
new file mode 100644
index 0000000000..c405db4ed8
--- /dev/null
+++ b/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc
@@ -0,0 +1,105 @@
+/**
+ * Issue ##3311 — dev-mode inline notice when ?reload=true is refused.
+ *
+ * The reload gate in the app template's onRequestStart() (fail-closed since
+ * ##3062) must RECORD why a requested reload did not fire, so the framework's
+ * debug bar (vendor/wheels/events/onrequestend/debug.cfm) can surface a
+ * development-only notice instead of a silent no-op. Recording lives in the
+ * template copies; message text and the development-environment gate live
+ * framework-side so wording can improve without template drift.
+ *
+ * Contract, which ALL FOUR same-lineage copies of public/Application.cfc must
+ * carry (same lineage as ReloadPasswordGateParitySpec.cfc):
+ *
+ * 1. A refused reload records request.wheels.reloadRefusedReason with one of
+ * exactly three reasons: "emptyPassword" (no non-empty reloadPassword is
+ * configured), "missingPasswordParam" (password configured but no
+ * password parameter supplied), "refused" (everything else).
+ * 2. NO ORACLE: wrong-password and rate-limited refusals must collapse into
+ * the single generic "refused" reason — the copies must not record a
+ * reason string that distinguishes them.
+ *
+ * Structural spec (no runtime): exercising the gate at runtime would
+ * applicationStop() the suite mid-run. Modeled on
+ * ReloadPasswordGateParitySpec.cfc.
+ */
+component extends="wheels.WheelsTest" {
+
+ function run() {
+
+ describe("reload-refused notice recording parity (issue ##3311)", () => {
+
+ // expandPath("/wheels") resolves to vendor/wheels via the
+ // configured Lucee mapping; the repo root is two levels above.
+ var repoRoot = expandPath("/wheels/../..");
+ var targets = [
+ "cli/lucli/templates/app/public/Application.cfc",
+ "public/Application.cfc",
+ "examples/tweet/public/Application.cfc",
+ "examples/starter-app/public/Application.cfc"
+ ];
+
+ for (var rel in targets) {
+ // Capture the loop variable so the closure body binds the
+ // current value, not the final iteration's value.
+ (function(relPath) {
+
+ it("records all three refusal reasons in " & relPath, () => {
+ var absolute = repoRoot & "/" & relPath;
+ expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute);
+ var content = fileRead(absolute);
+
+ expect(
+ content contains 'request.wheels.reloadRefusedReason = "emptyPassword"'
+ ).toBeTrue(
+ relPath & " must record reloadRefusedReason=emptyPassword when a "
+ & "reload is requested with no non-empty reloadPassword configured "
+ & "(issue ##3311)."
+ );
+ expect(
+ content contains 'request.wheels.reloadRefusedReason = "missingPasswordParam"'
+ ).toBeTrue(
+ relPath & " must record reloadRefusedReason=missingPasswordParam when "
+ & "a password is configured but the request carried no password "
+ & "parameter (issue ##3311)."
+ );
+ expect(
+ content contains 'request.wheels.reloadRefusedReason = "refused"'
+ ).toBeTrue(
+ relPath & " must record the generic reloadRefusedReason=refused for "
+ & "wrong-password/rate-limited attempts (issue ##3311)."
+ );
+ });
+
+ it("keeps wrong-password and rate-limited refusals indistinguishable in " & relPath, () => {
+ var absolute = repoRoot & "/" & relPath;
+ expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute);
+ var content = fileRead(absolute);
+
+ // The only assignments to the flag are the three contract reasons —
+ // no copy may grow a reason that leaks WHY the compare failed.
+ var assignments = REMatch("request\.wheels\.reloadRefusedReason\s*=\s*""[^""]*""", content);
+ expect(ArrayLen(assignments) == 3).toBeTrue(
+ relPath & " must assign reloadRefusedReason exactly three times "
+ & "(emptyPassword, missingPasswordParam, refused) — found "
+ & ArrayLen(assignments) & " (issue ##3311)."
+ );
+ for (var assignment in assignments) {
+ expect(
+ REFindNoCase("wrong|incorrect|rate", assignment) == 0
+ ).toBeTrue(
+ relPath & " records a refusal reason that distinguishes "
+ & "wrong-password from rate-limited — the notice must stay "
+ & "oracle-free (issue ##3311): " & assignment
+ );
+ }
+ });
+
+ })(rel);
+ }
+
+ });
+
+ }
+
+}
diff --git a/vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc b/vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc
new file mode 100644
index 0000000000..9f36a33a9a
--- /dev/null
+++ b/vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc
@@ -0,0 +1,116 @@
+/**
+ * Issue ##3311 — dev-mode inline notice when ?reload=true is refused.
+ *
+ * Since the fail-closed reload gate (##3062, 4.0.4), a refused ?reload=true is
+ * a silent no-op in the browser: the only signals are wheels_security.log and
+ * the boot warning, which keeps producing "reload is broken" support reports.
+ *
+ * The app template's reload gate now records WHY a requested reload did not
+ * fire in request.wheels.reloadRefusedReason ("emptyPassword",
+ * "missingPasswordParam", or the deliberately generic "refused" for
+ * wrong-password/rate-limited), and the debug bar renders a banner for it —
+ * in the development environment only. The "refused" reason must stay a
+ * single generic message so the notice adds no oracle distinguishing a wrong
+ * password from a rate-limited source.
+ */
+component extends="wheels.WheelsTest" {
+
+ function run() {
+ describe("debug.cfm reload-refused notice (issue 3311)", () => {
+
+ it("renders the empty-password notice in development", () => {
+ var output = $renderDebugBar(environment = "development", reason = "emptyPassword");
+ expect(output contains 'data-wdb-reload-refused="emptyPassword"').toBeTrue(
+ "the banner must render and carry the emptyPassword reason"
+ );
+ expect(output contains "reloadPassword is empty").toBeTrue(
+ "the banner must name the setting that disables URL reload"
+ );
+ expect(output contains "config/settings.cfm").toBeTrue(
+ "the banner must say where to set the reload password"
+ );
+ });
+
+ it("renders the missing-password-parameter notice in development", () => {
+ var output = $renderDebugBar(environment = "development", reason = "missingPasswordParam");
+ expect(output contains 'data-wdb-reload-refused="missingPasswordParam"').toBeTrue(
+ "the banner must render and carry the missingPasswordParam reason"
+ );
+ expect(output contains "password parameter").toBeTrue(
+ "the banner must say the password URL parameter is required"
+ );
+ });
+
+ it("renders one generic notice for wrong-password/rate-limited refusals", () => {
+ var output = $renderDebugBar(environment = "development", reason = "refused");
+ expect(output contains 'data-wdb-reload-refused="refused"').toBeTrue(
+ "the banner must render and carry the generic refused reason"
+ );
+ expect(output contains "wheels_security.log").toBeTrue(
+ "the generic notice must point at wheels_security.log"
+ );
+ // No oracle: the rendered notice must not say whether the password was
+ // wrong or the source was rate-limited.
+ expect(REFindNoCase("wrong|incorrect|rate.?limit", output) == 0).toBeTrue(
+ "the generic notice must not distinguish wrong-password from rate-limited"
+ );
+ });
+
+ it("renders no notice outside development even when a reason was recorded", () => {
+ var output = $renderDebugBar(environment = "testing", reason = "emptyPassword");
+ expect(output contains "data-wdb-reload-refused").toBeFalse(
+ "the notice is a development-only surface (issue 3311 acceptance criteria)"
+ );
+ });
+
+ it("renders no notice when no refusal reason was recorded", () => {
+ var output = $renderDebugBar(environment = "development", reason = "");
+ expect(output contains "data-wdb-reload-refused").toBeFalse(
+ "no banner without a recorded refusal"
+ );
+ });
+
+ });
+ }
+
+ /**
+ * Renders the debug bar template with the given environment and (optional)
+ * request.wheels.reloadRefusedReason applied, restoring all touched state.
+ * Modeled on DebugBarEnvQuickSwitchSpec.cfc.
+ */
+ private string function $renderDebugBar(required string environment, required string reason) {
+ var priorEnvironment = application.wheels.environment;
+ var priorReqWheels = StructKeyExists(request, "wheels") ? Duplicate(request.wheels) : {};
+ // debug.cfm bails out (cfexit) when url.format is one of json/xml/csv/pdf
+ // so it never breaks an API response. The test runner is hit with
+ // format=json — clear it for the duration of the include.
+ var hadUrlFormat = StructKeyExists(url, "format");
+ var priorUrlFormat = hadUrlFormat ? url.format : "";
+ var output = "";
+ try {
+ application.wheels.environment = arguments.environment;
+ if (!StructKeyExists(request, "wheels")) {
+ request.wheels = {};
+ }
+ request.wheels.execution = {total = 0};
+ request.wheels.params = {controller = "wheels", action = "tests", route = ""};
+ if (Len(arguments.reason)) {
+ request.wheels.reloadRefusedReason = arguments.reason;
+ } else {
+ StructDelete(request.wheels, "reloadRefusedReason");
+ }
+ if (hadUrlFormat) {
+ StructDelete(url, "format");
+ }
+ output = application.wo.$includeAndReturnOutput($template = "/wheels/events/onrequestend/debug.cfm");
+ } finally {
+ application.wheels.environment = priorEnvironment;
+ request.wheels = priorReqWheels;
+ if (hadUrlFormat) {
+ url.format = priorUrlFormat;
+ }
+ }
+ return output;
+ }
+
+}
diff --git a/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx b/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx
index 913a5d6317..46290567fc 100644
--- a/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx
+++ b/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx
@@ -503,6 +503,7 @@ These are additive in 4.0 and worth adopting during the upgrade window:
- **`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](#10-applicationwirebox-renamed-to-applicationwheelsdi).
- **"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.
+- **`?reload=true` does nothing — the app never restarts.** URL-based reload fails closed since 4.0.4: it requires a non-empty configured `reloadPassword` AND a matching `password` URL parameter (`?reload=true&password=...`). An empty `reloadPassword` — including the empty-string fallback from a missing `.env` key — disables URL reload entirely; in 3.x an empty password meant "no password required", so this breaks old muscle memory silently. In the development environment the debug bar now shows an inline notice explaining why a requested reload was refused; other environments log to `wheels_security.log` only. Wrong-password attempts are rate-limited (5 failures per IP within 5 minutes locks the source out for the window). See #3062, #3311.
- **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.