Skip to content

Release 4.0.4#3220

Merged
bpamiri merged 199 commits into
mainfrom
release/4.0.4-to-main
Jun 19, 2026
Merged

Release 4.0.4#3220
bpamiri merged 199 commits into
mainfrom
release/4.0.4-to-main

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Cuts Wheels 4.0.4 (197 commits since 4.0.3). Merges develop into main; main divergence healed via -s ours (the release tree is byte-identical to develop — verified empty git diff HEAD origin/develop).

Merge with a MERGE COMMIT, not squash (preserves develop history on main and permanently heals the divergence; the repo allows merge commits as of 2026-06-09). The push to main triggers release.yml → builds artifacts → softprops/action-gh-release tags v4.0.4 at the merge SHA → dispatches to homebrew/scoop/apt/yum + the develop auto-bump.

4.0.4 scope (116 changelog entries)

  • Security (5): trust-proxy-headers (X-Forwarded-* trust gate), model SQL-layer hardening (review remediation: model SQL layer — replace scope-arg denylist with parameterization, define select= validation policy, drop per-WHERE Migration instantiation (held: sql.cfc occupied by PR #2910) #2952), deploy remote-exec & remote-failure-secret redaction, reload password fails-closed
  • Performance (9): lock-free model()/controller() warm path, schema column cache, protected-methods O(1) lookup, EventMethods header memo, shared PluginObj, status-code/columnlist memoization
  • Added: wheels jobs work/status CLI, changelog.d fragment system, ArgSpec MCP input schemas, deploy env-secret delivery + warmup endpoint, subpath setting, upgrade --apply
  • Fixed (59): routing (scope/namespace callbacks, named capture groups), CORS global-middleware arbitration, RateLimiter memory/db stores, dispatch bare-cfabort on Adobe, tableName() getter guard, migrate info/doctor read-side fallbacks, CLI exit codes, BoxLang null-safety, and more
  • Changed: debug-bar externalized/cacheable assets, deploy validator hardening, MCP integration docs

Full notes: the # [4.0.4] section in CHANGELOG.md (extracted into the GitHub Release by release.yml).

bpamiri and others added 30 commits June 9, 2026 21:30
…ase playbook (#2893)

Learned at the 4.0.3 cut (#2892):

- The 'merge with a merge commit' step silently depended on the repo
  setting 'Allow merge commits', which had been switched to squash-only
  between the 4.0.1 and 4.0.2 cuts — forcing #2819 to squash and poisoning
  the next promotion with 24 spurious conflicts. The setting is enabled
  again (permanently, per maintainer decision 2026-06-09); the playbook now
  says so explicitly.
- Document the '-s ours' healing recipe for promoting develop over a
  diverged main, including the must-be-empty tree-diff verification.
- Promote the CHANGELOG rename on develop BEFORE cutting the release branch
  (the 4.0.3 flow, #2891) so develop doesn't need a back-port like #2824.
- Fix dispatch-list drift: chocolatey-wheels was retired for scoop-wheels,
  and the apt/yum bucket dispatches were missing from the trigger list.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: bpamiri <180555+bpamiri@users.noreply.github.com>
… in $isSafeRedirectUrl (#2898)

* fix(controller): reject backslash and schemeless open-redirect bypass in $isSafeRedirectUrl

$isSafeRedirectUrl passed /\evil.com, \/evil.com, \\evil.com, https:/evil.com,
and javascript:alert(1) as safe — browsers normalize backslashes to forward
slashes and single-slash schemes to authority form, navigating off-site.

- Reject any URL containing a backslash before the relative checks
  (matches the $generateIncludeTemplatePath precedent).
- Detect schemes case-insensitively with the RFC 3986 grammar
  (ReFindNoCase("^[a-z][a-z0-9+.-]*:")) instead of requiring a literal
  "://", so scheme-without-authority URLs (javascript:, mailto:, data:,
  https:/single-slash) are rejected instead of being treated as relative.
- Keep protocol-relative and same-domain absolute URLs allowed only on an
  exact host match; genuine relative paths remain allowed.

Verified RED then GREEN on Lucee 7 + SQLite via the worktree docker recipe:
pre-fix 33 pass / 5 fail (the five new attack vectors), post-fix 38 pass /
0 fail / 0 error for wheels.tests.specs.controller.redirectionSpec.

Finding T7 (open-redirect-backslash), internal framework review 2026-06-09.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): add [Unreleased] section for $isSafeRedirectUrl bypass fix (round 1)

Reviewer A and B converged on a single consensus finding: the bug-fix PR
was missing the CHANGELOG entry that CONTRIBUTING.md:187 requires. Adds
an `## [Unreleased]` section above [4.0.3] with a `### Security` bullet
summarizing the five open-redirect bypass vectors closed by this PR
(#2898).

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…wlisting (#2902)

* fix(dispatch): stop trusting X-Forwarded-For for debug-access IP allowlisting

The IP-based debug-access gate derived the client IP from
CGI.HTTP_X_FORWARDED_FOR ?: CGI.REMOTE_ADDR. CGI keys always exist (as
empty strings), so the elvis fallback never engaged and the allowlist
was matched against attacker-controlled header input: any client could
send X-Forwarded-For: <allowlisted IP> and switch on the public debug
GUI, debug output, and verbose error info in production. The same bug
also meant a legitimately allowlisted REMOTE_ADDR without the header
could never match.

Client IP now defaults to CGI.REMOTE_ADDR (the socket address). The
forwarded header is only consulted when the app explicitly opts in via
the new framework setting set(debugAccessTrustProxy=true), in which
case the rightmost X-Forwarded-For entry (the one appended by the
trusted proxy nearest the app) is used. The block is kept inline with a
StructKeyExists guard - not a Global helper - so the CLI template and
examples degrade to the secure REMOTE_ADDR-only path on older vendored
frameworks that lack the setting.

Applied uniformly to public/Application.cfc, the CLI app template, and
both example apps; default added in vendor/wheels/events/init/security.cfm.
Spec extends environment/ipbasedaccessSpec.cfc with a source regression
scan (vulnerable elvis pattern absent, gate present), the new default,
and the documented resolution semantics.

Behavioral note: apps using allowIPBasedDebugAccess behind a reverse
proxy must now set debugAccessTrustProxy=true to keep debug access.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): save/restore debugAccessTrustProxy in ipbasedaccessSpec teardown (round 1)

Address Reviewer A/B consensus on PR #2902:

- Add debugAccessTrustProxy to beforeAll save / afterAll restore in
  vendor/wheels/tests/specs/environment/ipbasedaccessSpec.cfc, using
  the same guarded StructKeyExists pattern as the other three
  application.wheels keys already handled there. Without it, any
  spec that mutates the new setting before this one runs would
  invalidate the "defaults to false" assertion at line 179.

- Add the missing trailing newline to ipbasedaccessSpec.cfc.

Skipped (consensus characterized these as follow-ups, not changes to
this PR): EventMethods.cfc deferred maintenance-mode XFF / except=
findings; multi-proxy topology docs; CHANGELOG migration note.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…nt datasource (#2904)

TenantMigrator was dead on arrival: $createMigrator instantiated the
nonexistent wheels.migrator.Migrator (the real component is
wheels.Migrator) and then called a nonexistent .migrate() method, so
every tenant landed in results.failed. Worse, the original lock
restored application.wheels.dataSourceName immediately after
construction — but the migrator reads the datasource lazily at query
time, so even a 'fixed' call would have migrated the wrong database.

- $runForTenant() holds the exclusive named lock for the FULL run and
  only restores the application datasource after the action completes
  (timeout bumped 30s -> 300s since the lock now spans real migrations)
- $newMigrator() creates wheels.Migrator with configurable
  migratePath/sqlPath (new optional migrateAll args, mirroring
  wheels.Migrator.init defaults)
- $executeAction() maps latest/up/down/info onto the real Migrator API
  (migrateToLatest, migrateTo via pending/applied version walks,
  $buildInfoOutput), mirroring vendor/wheels/public/views/cli.cfm
- migrateAll() validates the action up front
  (Wheels.TenantMigrator.InvalidAction) and snapshots/restores any
  pre-existing request.wheels.tenant context instead of deleting it
- helpers are public with $ prefix per framework convention

New TenantMigratorSpec covers latest/up/down/info against the fixture
migrations on the live test datasource, datasource + tenant-context
restoration, stopOnError both ways, action validation, and the
tenantProvider closure path. Verified red on the pre-fix code
(8 of 9 fail) and green post-fix; full migrator directory passes
(265/265) on Lucee 7 + SQLite.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…dition evaluator (#2905)

Two compounding defects in the validation condition evaluator:

1. $normalizeConditionOperators ran ReplaceList left-to-right, so "<"
   rewrote before "<=" and ">" before ">=", mangling them into
   "lt=" / "gt=" which $resolveOperator rejects. Now replaces
   two-character operators first, pads with spaces (also fixing
   un-spaced inputs like "a>=b"), and collapses whitespace.

2. $splitConditionOnOperator used unanchored FindNoCase, splitting
   inside identifiers ("frequency" matched "eq", "adult" matched
   "lt"), silently mis-evaluating conditions. Now splits on the first
   whitespace-delimited operator token via a longest-first anchored
   regex, with LCase for the case-sensitive Adobe CF switch in
   $resolveOperator.

3. $evaluateCondition caught any evaluation error and returned false,
   silently skipping the validation (fail-open on the data-integrity
   boundary). Development/testing (showErrorInformation=true) now
   throws Wheels.InvalidValidationCondition; production keeps the skip
   but logs to wheels-errors.

Known pre-existing limitation (unchanged): "<"/">" inside quoted
operands are still normalized into lt/gt tokens; the old code did the
same.

Verified red->green on Lucee 7 + SQLite: the 6 new specs fail against
the pre-fix code (5 failures + 1 error) and the full validationsSpec
bundle passes 125/125 with the fix.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ge (#2912)

* fix(config): isolate ServiceProvider register/boot failures per package

ServiceProvider register()/boot() ran with no per-package error isolation
in PackageLoader.cfc — one throwing provider aborted the whole application
boot, contradicting the per-package log-and-skip contract the loader
already applies at manifest-parse and load time.

PackageLoader.cfc: $invokeServiceProviderRegister/$invokeServiceProviderBoot
now iterate a Duplicate() snapshot of variables.serviceProviders and wrap
each provider in try/catch. A failure is logged to wheels.log, recorded in
failedPackages with the standard {name, error, detail} shape, and rolled
back via $rollbackPackage — which removes the key from serviceProviders so
the boot phase skips a register-failed provider with no extra tracking
state. Known limitations (documented in the docstrings): mixins/middleware
already merged into the application scope by Global.cfc::$loadPackages are
not unwound (the merge runs before the lifecycle invoke), and DI container
registrations made by a boot-failing provider cannot be unwound (the
Injector has no per-package tracking).

Plugins.cfc (legacy, deprecated): same snapshot + per-plugin try/catch,
log-and-skip only — a failing provider is dropped from
$class.serviceProviders via a new private $dropServiceProvider helper
(ArrayFind + ArrayDeleteAt, the proven $rollbackPackage pattern; the
legacy system has no failedPackages registry to mirror).

Behavior change: a throwing provider previously hard-failed boot with a
visible error page; the app now boots with the package skipped, an error
log line, and a getFailedPackages() entry — consistent with the documented
load-phase contract.

Tests: new vendor/wheels/tests/_assets/packages_sp/ fixtures (failregister,
goodsp, failboot) + ServiceProviderIsolationSpec covering register-failure
isolation, boot-skip after register failure, and boot-failure isolation;
new plugins/serviceproviderfailing fixture + pluginsModernSpec mirror case.
Pre-fix all four cases error because the fixture exception propagates
straight out of the unguarded loops. Verified on Lucee 7 + SQLite:
ServiceProviderIsolationSpec 3/3, pluginsModernSpec 32/32, full packages
directory 122/122.

Follow-up: the stale 'Also triggers instantiation of lazy ServiceProvider
packages' docstring claim is intentionally left for the DI9 review finding
(lazy-provider lifecycle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(config): re-sync app-scope failedPackages after ServiceProvider lifecycle

Review follow-up: application[appKey].failedPackages is assigned from
getFailedPackages() before the ServiceProvider register/boot invoke in
Global.cfc::$loadPackages. Adobe CF 2018-2025 copy arrays by value on
assignment (only Lucee/BoxLang share the reference), so lifecycle-phase
failure records appended by the new per-provider catch blocks never
reached the application-scope copy on Adobe — and that copy is what the
debug surfaces read (events/onrequestend/debug.cfm, packagelist.cfm),
turning a throwing provider into a silent skip with only a wheels.log
line on half the supported engines.

Re-read getFailedPackages() into the application scope immediately after
$invokeServiceProviderBoot: harmless on Lucee/BoxLang (re-assigns the
same reference), required on Adobe (fresh copy including lifecycle
entries). Also move the aggregate failed-package WARN summary below the
lifecycle invoke so register()/boot() failures appear in the same
high-visibility wheels.log / wheels-errors.log breadcrumb as load-phase
failures (previously it ran pre-invoke, so lifecycle failures were
missing from it on every engine). The summary block is unchanged apart
from a comment noting the new placement.

Verified on Lucee 7 + SQLite (worktree docker recipe):
ServiceProviderIsolationSpec 3/3 pass, app boot through the edited
$loadPackages path clean. Adobe behavior is by-inspection (array
copy-by-value semantics) — exactly the divergence the per-PR
compat-matrix cannot assert without an application-scope spec, which
would require re-running $loadPackages against the live test app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): exit non-zero when wheels validate finds errors

wheels validate printed its report and returned "" on every path, so the
process exited 0 even when validation found errors and CI could not gate
on it (framework review H5, same family as #2890 / CLI audit H6).

- errors found: record the failure inside the try, throw
  Wheels.ValidationFailed after the report is flushed (runTests pattern,
  out of reach of the catch-all)
- analyzer crash: the catch now prints then rethrows instead of
  swallowing, matching migrate()
- no app/ directory: throw Wheels.InvalidArguments after the red hint,
  matching the other user-error paths
- warnings-only stays exit 0: results.valid is true when no
  severity=="error" issues exist, so validate remains usable as a soft
  linter; output text and ordering are unchanged

Adds ValidateCommandSpec covering all four paths. Verified locally on the
Lucee 7 docker harness: 4/4 new specs pass, InfoCommandSpec's existing
validate case stays green.

Intentional behavior change: scripts that relied on exit 0 despite
reported errors will now fail; the MCP wheels_validate tool surfaces a
proper tool error instead of a silent empty result. Out of scope: U1
(wheels upgrade check exit code) and the runTests mid-run HTTP
catch-swallow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(cli): address Reviewer A/B consensus findings (round 1)

- Replace stale "Both commands always exit 0" claim in
  web/sites/guides/.../code-quality.mdx with accurate split:
  validate exits non-zero on errors, analyze always exits 0.
- Condense the two multi-line comment blocks in Module.cfc::validate()
  (3-line and 5-line) to single-line per CLAUDE.md "one short line max"
  convention — invariants preserved, just shorter prose.
- Condense the 11-line component docstring and 4-line $makeProject()
  docstring in ValidateCommandSpec.cfc to single-line comments for the
  same reason.

All changes are pure comment/text edits; no runtime behaviour change.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): address Reviewer A consensus findings (round 2)

- cli/lucli/tests/specs/commands/ValidateCommandSpec.cfc lines 7-8:
  condense 2-line comment block to single-line per CLAUDE.md
  ("Never write multi-paragraph docstrings or multi-line comment
  blocks — one short line max").
- cli/lucli/tests/specs/commands/ValidateCommandSpec.cfc lines 17-19:
  condense 3-line comment block to single-line, same rule.

Comment-only changes; no runtime behaviour impact. Test bodies and
the four-case coverage matrix are untouched.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…ed-row check (#2899)

* fix(job): guard processQueue job claim with status=pending and affected-row check

processQueue's $processJob marked a job as processing with an unguarded
UPDATE (WHERE id = :id only), so two concurrent workers could both claim
and execute the same job. Mirror JobWorker.cfc::$claimJob: add
AND status = 'pending' to the claim UPDATE and check the affected-row
count via the queryExecute result option on the same statement (a
separate verification SELECT breaks on BoxLang + PostgreSQL when the
pool hands out a different connection).

A lost claim now returns {skipped = true} from $processJob, and
processQueue counts it under a new additive 'skipped' key instead of
recording a failure. attempts increments only on a successful claim,
keeping increment-and-claim atomic.

Spec: new "Job Claim Guard" describe in JobQueueSpec proves an
already-processing row is not re-executed (status stays 'processing',
attempts stays 0), plus a 'skipped' key assertion on the processQueue
result shape. Verified red (22 pass / 2 fail pre-fix) then green
(24 pass / 0 fail) on Lucee 7 + SQLite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(job): address Reviewer A/B consensus findings (round 1)

- vendor/wheels/Job.cfc:238 — drop the redundant StructKeyExists guard
  on local.jobResult.skipped. $processJob initialises
  local.result = {success = false, skipped = false, error = ""}
  unconditionally and every return path returns that struct, so the
  key is always present.
- vendor/wheels/tests/specs/jobs/JobQueueSpec.cfc — move the
  "Job Claim Guard" describe block's DELETE cleanup from inline at
  the end of the it block into afterEach (try/catch-wrapped, matching
  beforeEach), so a throw before the inline DELETE no longer leaks
  the seeded row. beforeEach still cleans up too (defense in depth).
- CHANGELOG.md — add the [Unreleased] block (Keep a Changelog
  convention) with a ### Fixed entry for the processQueue claim guard.
  Last [Unreleased] was promoted to 4.0.3 in 08dd480 on 2026-06-09,
  so this PR is the first change targeting the 4.0.4 snapshot.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…n restrictions (#2901)

* fix(controller): make $acceptableFormats honor onlyProvides per-action restrictions

onlyProvides() stores per-action format restrictions in
variables.$class.formats.actions (provides.cfc), but $acceptableFormats()
read the top-level variables.$class.formats struct - whose only keys are
default/actions/existingTemplates/nonExistingTemplates - so the lookup
never matched and every onlyProvides() call was a silent no-op. The
function also read arguments.action without declaring it.

Fix the read path to check the .actions sub-struct and declare the
action argument as optional (default empty) so bare calls stay safe.
Both framework callers ($callAction, renderWith) already pass it named.

Behavioral change by design: onlyProvides() restrictions are now
enforced. renderWith() coerces non-acceptable formats to html, and the
$callAction auto-render block skips view rendering for non-acceptable
non-html formats - previously the restriction was ignored and the
requested format rendered anyway.

Adds read-path specs to providesSpec and a behavioral renderWith spec
to renderingSpec; all red against the pre-fix code (verified locally:
HTTP 417 pre-fix, 200 post-fix on Lucee 7 + SQLite).

Addresses finding T6 (onlyprovides-noop) of the 2026-06-09 framework
review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(controller): address Reviewer A/B consensus findings (round 1)

- Add CHANGELOG.md [Unreleased] entry documenting that onlyProvides()
  per-action format restrictions now take effect (silent no-op since
  introduction). Both reviewers explicitly aligned on the requirement;
  Reviewer B noted A had misattributed the source (it lives in
  CONTRIBUTING.md and .github/pull_request_template.md, not CLAUDE.md),
  but the underlying need to add the entry is correct.
- Add $callAction auto-render-path coverage in
  vendor/wheels/tests/specs/controller/renderingSpec.cfc — closes
  Reviewer A's gap on processing.cfc:165, the branch that becomes
  reachable now that $acceptableFormats reads .actions. The new spec
  proves that requesting xml against an action whose onlyProvides
  allows only json does NOT fall through to renderView (no
  ViewNotFound); response stays empty. Mirrors the read-path test
  cleanup pattern (try/finally around StructDelete on the cached
  application-scope class data).
- Skipped (not in consensus): A's suggestion to also link a follow-up
  issue for the html-fallback deferred behavior. Treated as nice-to-
  have prose, not a required change; PR body owner can add when ready.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(controller): address Reviewer A/B consensus findings (round 2)

- vendor/wheels/tests/specs/controller/renderingSpec.cfc — initialize
  `captured = ""` before the try block in the "skips view rendering when
  onlyProvides excludes the requested non-html format" spec (lines 779-804).
  Reviewer A's nit on the round-1 commit: if `$callAction` were to throw
  unexpectedly, an undefined `captured` would surface as a misleading
  "undefined variable" error instead of the rethrown dispatch failure.
  Both reviewers aligned — one-liner, consistent with the suite's
  pre-assert setup pattern, expected-no-throw path unchanged.

- CHANGELOG.md — shorten the [Unreleased] entry from ~114 words to ~60
  words. Reviewer A flagged the verbose form as breaking the Keep a
  Changelog convention of a short user-facing sentence or two (the
  root-cause enumeration belongs in the PR description). Reviewer B
  agreed on the verbosity ask while correctly noting A overstated the
  word count (~264 claimed vs ~114 actual); the underlying concern and
  the suggested shorter form are unchanged. Now keeps the bug summary,
  the read-path correction, and the behavior-change callout.

Controller specs verified on Lucee 7 / SQLite: 439 pass, 0 fail, 0 error,
1 skipped (pre-existing). renderingSpec bundle: 77/77.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore: defer changelog entry to campaign consolidation

wheels-bot added an [Unreleased] entry on this branch; with a dozen parallel remediation PRs these entries all conflict at the same CHANGELOG location as siblings merge. The entry text is preserved off-branch and will land in one consolidated changelog PR at the end of the remediation campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
#2909)

* fix(view): redact secret-shaped settings on the /wheels/info HTML page

The HTML branch of /wheels/info rendered csrfCookieEncryptionSecretKey in
plaintext via outputSetting() -> formatSettingOutput(get()), while the JSON
branch already omitted it through a hardcoded key compare. The two branches
could drift, and the HTML page leaked the live CSRF cookie encryption key.

Introduce a single source of truth on wheels.Public (2026-06-09 framework
review, finding T3):

- $isProtectedSetting(settingName): true for secret-shaped names
  (secret|password|passphrase|privatekey|apikey|credential|token), with an
  explicit exemption for *allowCredentials — accessControlAllowCredentials is
  the boolean CORS flag mirroring Access-Control-Allow-Credentials, not a
  credential value. Without the exemption it would have been redacted in HTML
  and silently dropped from the JSON cors collection (the triage plan missed
  this match; JSON output stays byte-identical today).
- $settingDisplayValue(settingName): returns <em>[redacted]</em> for
  protected names WITHOUT calling get(), so an unset key cannot throw;
  otherwise renders through formatSettingOutput(get()) as before.

outputSetting() in public/helpers.cfm now renders rows through
$settingDisplayValue(), covering every HTML settings table (environment,
paths, components, csrf, cors, settings tabs). The JSON branch in
views/info.cfm replaces the hardcoded csrfCookieEncryptionSecretKey compare
with !$isProtectedSetting() and applies the same guard to all collection
loops so future secret-shaped settings are omitted uniformly.

Both methods are public with a $ prefix per the mixin invariant; the new
functions live on Public.cfc (not helpers.cfm, which is double-included via
$init() and _header.cfm and also pulled into non-Public test contexts).
This change does not alter the production gating ($blockInProduction) —
reachability is a separate review finding; it only changes what leaks when
the page is reachable.

Verified red->green on Lucee 7 + SQLite via the worktree docker recipe:
pre-fix 0 pass / 2 fail / 7 error, post-fix 9 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(view): address Reviewer A/B consensus findings (round 1)

- Flatten the JSON-branch CSRF loop in vendor/wheels/public/views/info.cfm
  to match the compound-condition form already used by the other five
  collection loops (paths, components, environment, cors, settings).
  Behavior-preserving — combines the nested isDefined() + !$isProtectedSetting()
  guards via && and consolidates the two-line comment.

- Add a CHANGELOG.md [Unreleased] Security entry describing the
  csrfCookieEncryptionSecretKey HTML-page leak fix, the shared
  $isProtectedSetting() / $settingDisplayValue() predicate-and-helper pair
  on wheels.Public, and the accessControlAllowCredentials exemption.

Two reviewer findings are intentionally deferred (both A and B explicitly
framed them as follow-up scope):

- Adding a bare `key$` suffix to the predicate regex to catch hypothetical
  future settings named encryptionKey / signingKey (A: "Worth a follow-up
  issue ... before new settings land that use that naming style"). No
  current setting in the six rendered arrays uses that shape.

- Moving outputSetting() from helpers.cfm into Public.cfc as a private
  method so the wheels.Public-mixin caller contract is structural rather
  than commented (A: "before helpers.cfm gets reused more broadly").

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore: defer changelog entry to campaign consolidation

wheels-bot added an [Unreleased] entry on this branch; with a dozen parallel remediation PRs these entries all conflict at the same CHANGELOG location as siblings merge. The entry text is preserved off-branch and will land in one consolidated changelog PR at the end of the remediation campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…nt (#2903)

* fix(dispatch): block /wheels/* dev UI unless environment is development

$shouldBlockInProduction() was a denylist matching only the literal
"production", while the RCE-grade endpoints behind it (consoleeval.cfm,
mcp.cfm) already required environment == "development". Any other
environment name (staging, qa, testing, maintenance, design, ...) passed
the gate when enablePublicComponent was force-enabled.

Rewrite the predicate as a fail-closed allowlist: block when
application.wheels or application.wheels.environment is missing,
otherwise block unless the environment is development (case-insensitive
!=, same operator consoleeval.cfm uses). Method names are kept —
29 handler call sites and the static-coverage spec grep for them.

Behavioral change: setting enablePublicComponent=true in a
non-development environment no longer exposes the gated dev-UI handlers
(they 404); only environment="development" may reach them. index() stays
ungated per #2233. $loadRegistryPackages inherits the stricter gate.

Spec changes (all RED pre-fix, verified on Lucee 7 + SQLite):
- invert testing/maintenance/design predicate cases to expect blocking
- add arbitrary non-dev environment names (staging, qa, uat, prod,
  production-1)
- add fail-closed case for a missing environment key
- LoadRegistryPackagesSpec: staging now yields empty packages

Addresses finding T2 (S2/SEC-1) of the 2026-06-09 framework review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): add [Unreleased] section for $shouldBlockInProduction allowlist gate (round 1)

Reviewer A and B converged on a single consensus finding: the security
fix PR was missing a CHANGELOG entry for the denylist-to-allowlist
behavioral change. Adds an `## [Unreleased]` section above [4.0.3] with
a `### Security` bullet summarising the new fail-closed gate and the
behavioral delta for operators using `enablePublicComponent=true` in
non-development environments (#2903).

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore: defer changelog entry to campaign consolidation

wheels-bot added an [Unreleased] entry on this branch; with a dozen parallel remediation PRs these entries all conflict at the same CHANGELOG location as siblings merge. The entry text is preserved off-branch and will land in one consolidated changelog PR at the end of the remediation campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…entity retrieval (#2908)

* fix(model): prefer driver generated keys for Oracle and SQL Server identity retrieval

Identity retrieval after INSERT raced under concurrency on both adapters:

- Oracle resolved the new PK via WHERE ROWID = (SELECT MAX(ROWID) ...).
  ROWID is physical storage location, not insertion order, so a concurrent
  insert (or simply block reuse) can assign another session's key to the
  new object.
- SQL Server resolved it via a standalone SELECT @@IDENTITY, which is
  session-scoped: a trigger that inserts into another identity table makes
  it return the trigger's key instead of ours.

Both adapters now prefer the driver-supplied generated key from the insert
result struct (result.generatedKey on Lucee/ACF, result.rowid on ACF+Oracle),
mirroring the CockroachDB adapter. The driver retrieves the key in the
insert's own statement, so it is scope-, trigger-, and concurrency-safe.
Oracle uses a numeric key directly and resolves ROWID-shaped keys with an
exact-row CHARTOROWID lookup gated by a strict 18-char base-64 pattern
(Global.$query has no parameter binding, so nothing unvalidated is ever
interpolated).

The review's suggested SCOPE_IDENTITY()-first reorder was deliberately NOT
implemented: a standalone SELECT SCOPE_IDENTITY() executes in its own scope
(a batch is a scope per MS docs) and returns NULL - the documented BoxLang
empty-value behavior - so the reorder would add a wasted round-trip per
insert and still resolve via @@IDENTITY. The @@IDENTITY -> SCOPE_IDENTITY
chain and the MAX(ROWID) query are kept as documented last resorts for
engines that surface no generated key (currently BoxLang), with comments
stating their hazards. Both adapters also gain the BoxLang ReplaceList
column-parse workaround already used by the PostgreSQL/CockroachDB adapters
so the fallback path cannot falsely match the PK and skip key retrieval.

New DB-free unit specs (modeled on CockroachDBUnitSpec) fail against the
pre-fix adapters (2 errors per bundle on Lucee 7 + SQLite) and pass with
the fix; the database spec area is green (44 pass / 0 fail / 0 error).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): address Reviewer A/B consensus findings (round 1)

- Add DB-free spec exercising the ACF result.rowid numeric path in
  vendor/wheels/tests/specs/database/OracleUnitSpec.cfc. The Oracle
  adapter's $identitySelect reads two driver-key surfaces — Lucee's
  result.generatedKey (already covered) and ACF's result.rowid
  (uncovered) — and Reviewer A provided the exact spec to close that
  gap. A numeric rowid returns before any $query() call, so the case
  is fully DB-free.
- Add [Unreleased] entry to CHANGELOG.md describing the Oracle
  MAX(ROWID) / SQL Server @@IDENTITY data-integrity fix this PR
  introduces. CHANGELOG had no [Unreleased] section above the 4.0.3
  entry; Reviewer A flagged it as a nit that would confuse
  release-note time.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore: defer changelog entry to campaign consolidation

wheels-bot added an [Unreleased] entry on this branch; with a dozen parallel remediation PRs these entries all conflict at the same CHANGELOG location as siblings merge. The entry text is preserved off-branch and will land in one consolidated changelog PR at the end of the remediation campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
#2910)

* fix(model): key association JOIN memo by soft-delete and alias context

$expandedAssociations() memoized the built JOIN string onto the shared application-scoped
association struct keyed only on existence, so the first caller's context won permanently:
a join built with includeSoftDeletes=true was later served to default calls (dropping the
soft-delete IS NULL predicate), and an aliased self-join built in a nested include context
was served to top-level includes (and vice versa). The unlocked write also raced across
concurrent first hits.

Fix:
- memoize per context variant (joinVariants struct keyed by soft-delete flag + alias flag,
  1/0 ternary so keys stay engine-stable on Adobe CF)
- write the memo under a double-checked named lock taken only on memo miss, keeping the
  steady-state hot path lock-free
- return per-call shallow copies carrying the context-correct join, so callers are immune
  to concurrent re-memoization; every returned element always has a join key, preserving
  the StructKeyExists guards on the whereClause UPDATE paths
- stop writing the legacy join key to the shared struct (no framework reader exists outside
  the returned arrays)

The context-independent metadata fill-ins above the memo (foreignKey/joinKey/tableName/etc.)
still write to the shared struct unlocked; they are idempotent and left for a follow-up.

New spec ExpandedAssociationsJoinMemoSpec demonstrates the poisoning (all four cases fail
against the pre-fix code) and asserts the memo is still populated per variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): add [Unreleased] section for $expandedAssociations() JOIN memo fix (round 1)

Reviewer A and B converged on a single consensus finding: the fix was
missing a CHANGELOG entry. Adds an `## [Unreleased]` section above
[4.0.3] with a `### Fixed` bullet calling out both bugs the memo-key
change resolves (soft-delete predicate drop on default callers when an
includeSoftDeletes call ran first, and alias-context bleed across
nested vs top-level self-referential includes) plus the plugin-
compatibility callout that the legacy `join` key is no longer written
to the shared application-scoped association struct (#2910).

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore: defer changelog entry to campaign consolidation

wheels-bot added an [Unreleased] entry on this branch; with a dozen parallel remediation PRs these entries all conflict at the same CHANGELOG location as siblings merge. The entry text is preserved off-branch and will land in one consolidated changelog PR at the end of the remediation campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…rsal (#2900)

* fix(events): validate url.format to prevent error-template path traversal

$getRequestFormat() previously returned url.format verbatim, and
$runOnError interpolates that value into the on-disk error-template
include path (eventPath/onerror.<format>.cfm) guarded only by
FileExists. An unvalidated value such as ../../somefile enabled path
traversal / local file inclusion of any .cfm on disk during production
error rendering (review finding T4, 2026-06-09).

Only accept a plain alphanumeric token in the url.format branch — this
covers every configured format key (html, xml, json, csv, pdf, xls) and
any addFormat() extension — and fall back to the existing html default
for anything else (traversal sequences, dots, slashes, empty string).
The http_accept branch is untouched: it already only yields configured
format keys via the $get("formats") iteration.

Adds regression specs to onerrorSpec.cfc that save/restore url.format
around each case; verified red (3 failing) pre-fix and green post-fix
on Lucee 7 + SQLite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(events): address Reviewer A/B consensus findings (round 1)

- CHANGELOG.md: add [Unreleased] Security section documenting the
  $getRequestFormat LFI fix so operators upgrading know what was
  patched (#2900).
- vendor/wheels/events/EventMethods.cfc: collapse the 8-line security
  rationale comment before the ReFind guard into a single line per
  CLAUDE.md's inline-comment convention; the WHY still reads at a
  glance without bloating the function body.
- vendor/wheels/tests/specs/events/onerrorSpec.cfc: collapse the
  9-line block comment before the new "T4 LFI" describe into one line,
  and remove the 6-line /** */ docstring on the $requestFormatFor
  helper (the helper name + 13-line body are self-describing).

Both reviewers converged on these changes; no functional code or test
behavior is altered.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore: defer changelog entry to campaign consolidation

wheels-bot added an [Unreleased] entry on this branch; with a dozen parallel remediation PRs these entries all conflict at the same CHANGELOG location as siblings merge. The entry text is preserved off-branch and will land in one consolidated changelog PR at the end of the remediation campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…L and purge (#2913)

* fix(middleware): enforce database-backed rate limits with portable DDL and purge

Database storage in the RateLimiter middleware never actually enforced:
$ensureTable used MySQL-only DDL, swallowed every error, and latched
tableVerified unconditionally, while $dbIncrement relied on a duplicate-key
throw that never fired because store_key only had a plain (non-unique)
index. Result: fixedWindow + database did no limiting on MySQL, and
fail-closed 429'd ALL traffic on every other engine.

Changes (all in vendor/wheels/middleware/RateLimiter.cfc):

- $queryOptions(): lazily resolve application.wheels.dataSourceName once
  and pass it to all rate-limit queries, so database storage works without
  a default datasource (apps with this.datasource keep working via {}).
- $ensureTable(): now boolean and fail-closed-aware. Probes for an
  existing table first (accepts legacy MySQL tables with the old id
  column), creates with per-engine types via $detectDatabaseType()
  (copied from Job.cfc's CI-proven map; SQL Server gets DATETIME, never
  TIMESTAMP/rowversion; Oracle gets VARCHAR2), re-probes once on create
  failure (concurrent node), logs errors, and only latches tableVerified
  on verified success. Failed attempts are throttled so a broken config
  doesn't run DDL per request but can still recover.
- All three $db* strategies honor failOpen via $handleError('table
  unavailable') when the table can't be verified, instead of throwing
  per-request DML exceptions.
- $dbIncrement(): UPDATE-first counter algorithm (UPDATE, SELECT MAX,
  INSERT only when no row, re-read once on insert race). Enforces
  correctly on every engine, with or without a unique index, against old
  or new table shapes. No UNIQUE index is added on purpose: the sliding
  window strategy stores one row per request under the same store_key.
- $dbPurgeExpired(): throttled global purge of expired rows. Deviation
  from the triage plan: the cutoff trails Now() by windowSeconds instead
  of purging at Now(), because the token bucket strategy stores its
  last-refill time in expires_at - purging at Now() would delete live
  buckets. A bucket idle longer than windowSeconds is fully refilled, so
  deleting it is semantically a no-op.
- init(): drop the verbatim duplicate windowSeconds/maxRequests
  validation blocks (merge artifact, zero behavior change).

New spec vendor/wheels/tests/specs/middleware/RateLimiterDatabaseSpec.cfc
verified RED against the pre-fix code (6/8 fail or error on Lucee 7 +
SQLite) and GREEN after (8/8).

Behavioral note for the changelog: MySQL apps using storage=database
believed they had rate limiting - over-limit traffic now actually gets
429s; non-MySQL apps stop 429ing all traffic.

Deferred follow-up (to be filed as an issue): dialect-specific atomic
upsert to close the bounded first-insert race undercount, schema redesign
with a row-type discriminator, and locking for the DB tokenBucket /
slidingWindow read-modify-write races.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(web/guides): reword rate-limit table auto-creation to probe-then-create

The database storage section claimed the wheels_rate_limits table is
auto-created via CREATE TABLE IF NOT EXISTS; the RateLimiter now probes
for the table first and creates it with engine-appropriate DDL when
missing. Reword the parenthetical to match the actual mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* chore(web): refresh visual baseline(s) (blog)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml
on branch peter/review-ratelimiter-db-enforcement.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

* chore: retrigger CI after baseline-refresh bot push

The refresh-visual-baselines workflow pushed with GITHUB_TOKEN, which does not trigger pull_request workflows, leaving the required Bot PR TDD Gate check unreported on the new head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…encoding (#2917)

Two findings from the 2026-06-09 framework review (events-bootstrap-debugbar
package):

- DC1: commit 4cda23e accidentally chained engine detection to the
  reloadPassword carryover block with else-if in onapplicationstart.cfc.
  If the carryover branch ever fired, engine detection was skipped and the
  serverVersion deref on the next line would throw. Currently dormant (the
  branch is unreachable in shipped flows) but a latent landmine — drop the
  else so engine detection always runs.

- DC10 / SEC-7: the dev debug bar reflected client-controlled values
  (params.key, param names, cgi.query_string/path_info, controller/action/
  route, and the query-string-bearing baseReloadURL hrefs) into HTML without
  encoding — reflected XSS on every dev page and, via allowIPBasedDebugAccess,
  in non-dev environments for allowlisted admins. Param values were already
  encoded; encode the rest with EncodeForHTML / EncodeForHTMLAttribute.

New spec events/debugBarEncodingSpec.cfc renders debug.cfm with script-tag
payloads in params.key and a param name; verified red against the pre-fix
template (417) and green after (200) on Lucee 7 + SQLite, along with the
rest of the events spec area (31 pass / 0 fail).

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Address two view-helper review findings (view-helpers:4, view-helpers:18):

imageTag (vendor/wheels/view/assets.cfc):
- Rename the generated wheelsid attribute back to id with an
  attribute-boundary regex instead of a first-occurrence whole-string
  ReplaceNoCase, so attribute values containing the text "wheelsid"
  (e.g. an alt text) are no longer corrupted.
- Remove the always-true `local.localFile &&` condition inside the
  local-file branch of $imageTag.
- Replace the stale "cfinvoke" comment with one describing the actual
  invocation path ($doubleCheckedLock -> $invoke -> cfinvoke).

$viteManifest (vendor/wheels/view/vite.cfc):
- Cache the missing-manifest (negative) result as an empty struct when
  not throwing, so opted-out production apps (viteStrictManifest=false)
  no longer re-run GetDirectoryFromPath + FileExists on every vite tag.
- Guard the cache write with a named lock using double-checked locking.

Specs verified RED on the pre-fix code and GREEN post-fix on Lucee 7 +
SQLite (assetsSpec 32 pass, viteSpec 36 pass, 0 fail/error).

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…d highlight guards (#2919)

Hardens view link + misc helpers per the 2026-06-09 framework review
(package view-links-misc):

- $autoLinkLoop: compute the www.-fallback protocol per match in a local
  variable instead of mutating arguments.protocol as loop state, which
  prefixed later absolute URLs with http:// (view-helpers:1)
- $paramsToQueryString + $getValueByDynamicPath: scope the loop variable
  as local.key so it no longer leaks into the controller's variables
  scope; URL-encode the query-string key as well (view-helpers:2)
- $tag: add the missing else branch so addClass becomes the class
  attribute when no class exists, instead of rendering a literal
  addclass="..." attribute and dropping the class (view-helpers:3)
- $getValueByDynamicPath: replace the dead bracket-segment regex (the
  CFML-literal backslashes made the alternative unmatchable) so quoted
  keys with non-word characters tokenize correctly, and guard the
  invoke() fallback: objects keep navigating remaining segments, while
  unresolvable struct/array segments throw Wheels.ObjectNotFound rather
  than falling through to arbitrary method execution (view-helpers:7)
- flashMessages(): guard the local.flash[item] read so requesting a key
  that was never flashed returns an empty result instead of throwing on
  every page (view-helpers:8)
- highlight(): initialize local.newText to the text before the loop so a
  phrase list that collapses to an empty array (e.g. phrase=",") returns
  the text unchanged instead of an undefined-variable error
  (view-helpers:10)
- $decodeHtmlEntities(): cap decoded numeric character references to the
  valid Unicode range 1..0x10FFFF; overlong or out-of-range entities are
  left untouched instead of crashing Chr() inside the pagination XSS
  scrub (view-helpers:12)
- linkTo/buttonTo: use StructCopy instead of Duplicate for the URLFor
  argument copy, avoiding a deep clone of model objects passed as key on
  every link (view-helpers:14)

Specs: vendor/wheels/tests/specs/view/linksMiscHardeningSpec.cfc covers
the behavioral fixes. Verified on Lucee 7 + SQLite: new bundle 13/13,
full view directory 569 pass / 0 fail, PaginationXssSpec 14/14.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ude path (#2923)

Addresses three review findings in the Seeder and helpers area:

- seedOnce() now throws Wheels.Seeder.InvalidUniqueValue when a unique
  property value is non-simple instead of silently dropping it from the
  uniqueness predicate, and throws Wheels.Seeder.EmptyUniqueProperties
  when the resulting WHERE clause is empty (previously findOne(where="")
  matched an arbitrary row and the seed was recorded as skipped without
  ever being created). The interpolated WHERE string is kept since the
  framework's $whereClause parses and parameterizes literal values; the
  guards close the actual correctness hole. (jobs-misc:7)

- runSeeds() validates the environment name against ^[A-Za-z0-9_-]+$
  before interpolating it into the seeds/<environment>.cfm include path,
  closing a path-traversal vector from the CLI bridge. (jobs-misc:10)

- Delete vendor/wheels/helpers/DatabaseShellHelper.cfc, a 250-line
  orphan with zero callers anywhere in the repo (verified: no references
  to the component or its three methods). (jobs-misc:8)

New specs cover all four throw paths in seederSpec.cfc; full bundle
green on Lucee 7 + SQLite (15 pass, 0 fail).

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…in Base (#2920)

Four fixes to the Base adapter query path (FRAMEWORK-REVIEW-2026-06-09,
db-adapters package):

- db-adapters:4 (high, perf): $executeQuery probed $dbinfo(type="version")
  on every single query — a JDBC metadata round-trip per INSERT/UPDATE/
  DELETE/SELECT — just to pick Oracle OFFSET/FETCH vs LIMIT syntax. The
  adapter type already identifies the database product, so the probe is
  replaced by a new overridable $limitOffsetClause() method (Base emits
  LIMIT/OFFSET; OracleModel overrides with OFFSET/FETCH).

- db-adapters:2 (medium, robustness): the parameterize=false IN-list branch
  kept its own parentheses inside the pair the 33cf69b refactor hoisted
  out, emitting "IN ((1,2,3))" — a row-constructor syntax error on every
  supported database (SQLite: "row value misused"). Inner pair removed.

- db-adapters:12 (medium, perf): $performQuery deep-cloned the entire
  arguments struct (including the full SQL fragment array with param
  structs) via Duplicate() just to StructDelete six keys. Now copies only
  the non-excluded keys by reference.

- db-adapters:13 (low, perf): $moveAggregateToHaving ran the
  $isAggregateFunction regex on every SQL fragment of every query even
  though the HAVING rewrite only applies when a GROUP BY exists. Now does
  the cheap GROUP BY scan first, early-returns without it, and breaks out
  of the aggregate scan on first match.

New spec adapterBaseQueryPathSpec.cfc verified red on the pre-fix code
(IN spec errors with "row value misused"; $limitOffsetClause specs error
with method-not-found) and green post-fix on Lucee 7 + SQLite, alongside
sqlSpec and calculationsSpec (57 pass, 0 fail/error).

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…e copy semantics (#2924)

- $resolveRouteModelBinding: resolve the model class in its own try and run
  findByKey() outside it so query errors propagate instead of being masked as
  a missing model; rethrow resolution failures for explicit binding names
  (binding="BlogPost") since they indicate configuration errors; log a
  dev-mode breadcrumb on conventional misses (routing:8).
- Negative-cache conventional binding misses in application.wheels (cleared
  on reload) so a non-model-backed controller no longer repeats the app-wide
  model lock + base-model bootstrap + DB metadata query on every keyed
  request (routing:13).
- $findMatchingRoute: unify static and regex match paths on a single
  $copyRouteForRequest helper (shallow top-level copy with non-simple
  members duplicated) so the static fast path can no longer leak shared
  nested route state and the regex path no longer deep-Duplicates the whole
  struct per request; Mapper comment synced (routing:12, routing:16).
- Execute the route regex once per request: $findMatchingRoute stashes the
  sub-expression match on the per-request copy and $mergeRoutePattern reuses
  it, falling back to a fresh match when absent (routing:17).
- $translateDatePartSubmissions: delete the ($ampm) part key during cleanup
  so 12-hour time submissions no longer leave a raw field($ampm) param
  (dispatch-core:5).

Specs added for each behavioral change.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…claims (#2928)

Remediates the docs-migrator-reference package from the 2026-06-09
framework review (refs upgrade-docs:8,9,10,12 / followups:37,38,40).

- D1 [code defect]: public/docs/core.cfm gated the migrator /
  migration / tabledefinition API-reference scopes on
  enablePluginsComponent, but application.wheels.migrator is created
  under enableMigratorComponent (onapplicationstart.cfc). With
  plugins off + migrator on the migrator reference silently vanished;
  with the inverse combination the page errored on a non-existent
  application.wheels.migrator. Now gated on enableMigratorComponent.
  Guarded by the new DocsViewerMigratorFlagSpec (verified RED on the
  pre-fix gate, GREEN post-fix, Lucee 7 + SQLite).

- D2: announce.txt, float.txt, and text.txt examples used Ruby-style
  trailing-block createTable(...) { ... } syntax (a CFML parse error
  that never assigns t or calls t.create()) and the no-op null=
  argument (silently dropped; the real flag is allowNull). Rewritten
  in the canonical t = createTable(...); ... t.create(); form with
  allowNull=.

- D3: references.txt asserted userId/commentableType column names,
  but the suffix is resolved through useUnderscoreReferenceColumns
  (true in the wheels new template), so fresh apps get user_id /
  user_type. Examples now use the modern columnNames= argument
  (per ##2781) and document both suffix outcomes. The same legacy
  referenceNames= usage in change.txt and timestamps.txt (identical
  defect class, same reference directory) is updated too.

- D5: migrations.mdx claimed a failing up()/down() always rolls back
  cleanly. Added a caution Aside: MySQL and Oracle auto-commit DDL,
  so mid-migration failures leave partial schema there; pointed at
  migrate doctor + reconciliation subcommands.

Not done: the suggested CI lint compiling reference/**/*.txt examples
is out of scope for this docs-remediation diff — most snippets are
intentional fragments (bare t.string(...) lines without a surrounding
component) that cannot compile standalone.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…name ordering (#2932)

Addresses five review findings from the 2026-06-09 framework review
(middleware-auth:4,5,6,7,9):

- Cors (MA5): normalize allowOrigins in init() — split the comma list and
  trim each entry so origins listed after a space match; the wildcard +
  credentials guard now trims too.
- SecurityHeaders (MA9): dual-phase environment lookup (application.$wheels,
  then application.wheels) so the production HSTS auto-default fires for
  route-scoped string middleware instantiated after application start,
  mirroring AuthMiddleware.
- AuthMiddleware (MA4): delegate restricted-strategy authentication to the
  Authenticator via new authenticateWith(request, strategies); removes the
  drifted duplicate strategy loop and hand-rolled AuthResult struct. The
  restricted path now shares the zero-strategies diagnostic and surfaces a
  wiring error when restricted to only unregistered strategy names.
  Implemented as a separate public method instead of an extra authenticate()
  argument so the AuthenticatorInterface signature stays untouched.
- MiddlewareOrderResolver (MA7): suffix duplicate middleware names during
  normalization so they no longer collapse to one graph node, falsely trip
  the circular-dependency fallback, and discard all before/after
  constraints; the duplicate warning is now emitted there with the
  pluginName dereference guarded.
- TenantResolver (MA6): shared $invokeResolver helper gives all three
  strategies consistent IsStruct/false-sentinel handling; fixed the stale
  subdomain-strategy comment.

Verified locally: middleware + auth spec directories on Lucee 7 + SQLite
(169 + 163 pass, 0 fail, 0 error).

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ffAll rethrow (#2937)

Addresses seven framework-review findings in the migrator (refs migrator:3,
migrator:5, migrator:7, migrator:9, migrator:11, migrator:13, migrator:14):

- Migration.addIndex(): compute the default indexName in the body after
  $combineArguments resolves the columnName alias — the parameter-default
  expression dereferenced arguments.columnNames and threw an undefined-key
  error on the documented columnName path (MG3).
- Base.$getDBType(): memoize the resolved adapter name per datasource on the
  application scope; discovery instantiates every migration CFC and each
  init() previously triggered a fresh $dbinfo(version) round-trip (MG11).
- Base.$execute()/$executeWithParams(): extract the duplicated
  terminator/SQL-file/debug-capture pipeline into $prepareMigrationSql(),
  replace the per-statement $dbinfo(version) round-trip with the memoized
  $getDBType(), and give $executeWithParams a dataSource override matching
  its sibling (MG5).
- Base.$getColumns(): request-scoped cache keyed by datasource + table;
  $execute() drops the cache so same-request DDL is re-probed. addRecord()
  now probes table metadata once per record instead of twice (MG13).
- Migration.dropTable(): use this.adapter.adapterName() instead of
  re-sniffing the engine; Base.$getForeignKeys() probes the single table
  with a zero-row SELECT instead of listing every table in the schema (MG14).
- TableDefinition: dedupe the fourteen byte-identical typed column helpers
  into a shared $addTypedColumns() helper, preserving the float() and
  uniqueidentifier() outlier defaults and timestamp()'s columnType
  pass-through verbatim (MG7).
- AutoMigrator.diffAll(): validate heuristicThreshold up front and rethrow
  deliberate validation throws (InvalidThreshold, InvalidRenameHint,
  DuplicateRenameHint, RenameHintTypeMismatch) instead of swallowing them in
  the per-model missing-table catch and reporting "no drift" (MG9).

New specs (verified red on pre-fix code, green post-fix, Lucee 7 + SQLite):
MigratorBaseCachingSpec (both caches), migrationCommandsSpec (addIndex
columnName alias), autoMigratorSpec (diffAll threshold + hint rethrow).
Full migrator area: 270 pass / 0 fail / 0 error / 6 skipped.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…per dedup and perf (#2938)

Review wave 2, package view-form-helpers. Addresses view-helpers:5,6,9,11,13,15,16,17.

- $option(): only treat the bound value as a list for multi-selects; single selects
  now use exact comparison so a bound value like "Doe,John" no longer marks its
  comma-segment options selected on edit forms (view-helpers:11).
- startFormTag(): always rewrite non-get/post verbs to post plus a _method hidden
  field when the route/verb match fails or never runs, instead of rendering an
  invalid method attribute that browsers silently turn into a GET submit
  (view-helpers:9). Updated the PUT/PATCH/DELETE specs that codified the old
  fall-back behavior and added a no-controller regression spec.
- startFormTag(): memoize the linear route scan per request via
  request.wheels.startFormTagRouteCache (mirrors URLFor's urlForCache); only
  successful lookups are cached and entries are re-validated against
  namedRoutePositions so intra-request route changes stay correct (view-helpers:15).
- $dateOrTimeSelect(): implement the documented invariant that ampm in the order
  implies twelveHour (previously threw "Invalid type specified: ampm"), and drop
  the dead re-assign / always-true re-test from the old guard (view-helpers:5).
- $yearMonthHourMinuteSecondSelectTag(): hoist the deep Duplicate(arguments) out of
  the per-option loops; only the counter/optionContent keys mutate per iteration
  (view-helpers:13).
- $optionsForSelect(): convert the column list to an array once and stop scanning
  rows as soon as both valueField and textField are inferred (view-helpers:16).
- New $primeBoundObject() resolves the bound model once per helper invocation and
  threads it to $formValue/$maxLength/$formHasError/$getFieldLabel via a
  $boundObject key (skipped by $tag's $-prefix attribute filter). The date-select
  helpers are deliberately not primed because their per-select Duplicate(arguments)
  would deep-copy the primed object (view-helpers:17).
- New $encodeArgsForHtml() centralizes the encode-prepend/append block previously
  duplicated with drift across forms.cfc (x6), errors.cfc and links.cfc, and the
  dead encode assignment in endFormTag is removed (view-helpers:6).

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nderflow guard (#2925)

Addresses three wave-2 review findings in the mapper (routing:1, routing:5,
routing:11):

- resources()/resource() only/except lists are now normalized (Trim + LCase
  per item) and except filtering uses array operations instead of a
  case-sensitive ReReplace, so except="Delete" or except="new, delete" can
  no longer silently leave excluded destructive routes registered. Unknown
  action names throw Wheels.InvalidResource when showErrorInformation is
  enabled (development) and are dropped otherwise.
- namespace(), package(), and group() now forward all arguments to scope(),
  so options like middleware and binding are no longer silently dropped.
  api() and version() delegate to group() and inherit the fix.
- end() now throws a clear Wheels.InvalidRoute error on scope-stack
  underflow (an extra end()) instead of a raw array-index engine error.

Specs: vendor/wheels/tests/specs/mapper/MapperRobustnessSpec.cfc covers all
three behaviors and fails against the pre-fix code.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…, scoped loop vars (#2930)

* fix(controller): usesLayout crash, verifies bounds, only/except dedup, scoped loop vars

Wave-2 remediation for the controller-mixin-quality review package:

- C3: usesLayout() duplicate detection compared struct counts only, so two
  declarations with the same key count but different key sets (only vs
  except) threw a key-doesn't-exist error at config time. Now checks key
  presence before comparing values, scopes the arguments[key] read, and
  fixes the layoutPostionInArray typo.
- C4: extracted the only/except action-gating predicate duplicated in
  filters.cfc, verifies.cfc and csrf.cfc into a shared
  $appliesToAction() helper (miscellaneous.cfc) with documented
  both-lists semantics. layouts.cfc's AND-composed variant is left
  untouched since its semantics intentionally differ.
- C5: scopeMap in processAction's cache-key loop was assigned without
  local scoping (leaking live scope references into the controller's
  variables scope) and rebuilt per loop item; now local.scopeMap built
  once. caching.cfc clearCachableActions used a bare i index.
- C6: removed the transposed Find(referer, "?") condition in
  redirectTo() back-link handling; the enclosing else branch already
  guarantees the referer has no query string.
- C7: verifies() now validates at declaration time that cookieTypes /
  sessionTypes / paramsTypes list lengths match their variable lists,
  throwing Wheels.InvalidVerification instead of a raw array
  out-of-bounds error at request time.
- C18: $runVerifications() returns early when no verifications are
  declared and caches the GetApplicationMetadata() session-management
  probe in the request scope.
- C19: replaced the unbounded comma-list file-existence caches
  (existing/nonExisting helper, layout and format-template lists) with
  boolean-valued struct caches, eliminating repeated linear list scans
  and racy ListAppend lost updates. The proper-case existingObjectFiles
  list in Global.cfc is intentionally unchanged (it stores cased paths,
  not booleans).

Specs: usesLayoutDedupSpec (fails pre-fix), appliesToActionSpec,
verifiesSpec declaration-time validation case; updated the two specs
that injected the old list caches directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(controller): treat declared-but-unset keys as equal in usesLayout dedup

On Lucee and Adobe the arguments scope contains every declared parameter,
so iterating it yields declared-but-unset keys (null-valued) for which
StructKeyExists() returns false. The key-presence guard therefore marked
two identical usesLayout() declarations as different (via the unset
`except` key) and appended a duplicate instead of replacing. Compare
key presence on both sides: unset-on-both is equal, set-on-one-side is
different, set-on-both compares values.

Caught by the local Lucee 7 + SQLite run of usesLayoutDedupSpec
("replaces an identical declaration instead of duplicating it" expected
1, got 2); controller area now 452 pass / 0 fail / 0 error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…der alignment (#2931)

Addresses four model write-path findings from the 2026-06-09 framework review
(refs model-orm:2, model-orm:5, model-orm:6, model-orm:7):

- $update no longer gates updatedAt stamping on the create-only
  setUpdatedAtOnCreate setting (copy-paste drift from the create path).
  Both paths now share $stampTimestampProperty() on miscellaneous.cfc so
  the create and update timestamp rules cannot drift again.
- $create drops the unbounded per-insert findOne UUID uniqueness probe
  (the primary key constraint already guarantees uniqueness; collision
  probability is ~2^-122), aligns the explicitly-set and generation blank
  checks (an empty-string PK now triggers generation instead of silently
  inserting blank), and tightens $isUUIDColumn to require an explicitly
  reported 36-character size instead of also matching null/missing sizes.
- ScopeChain.$mergeSpecs now splits the WHERE string on '?' once and
  rejoins with quoted values, so a substituted value containing a literal
  '?' can no longer absorb the next placeholder and shift the remaining
  whereParams.
- $associationMethod's four ~30-line key-or-object dispatch blocks
  (setObject/addObject/removeObject/deleteObject) are deduplicated into a
  shared $resolveAssociationTarget() helper with identical behavior.

New specs: updateTimestampGatingSpec, createUuidKeySpec (backed by a new
c_o_r_e_uuidrecords char(36)-PK table + UuidRecord asset model), and
scopeChainWhereParamsSpec. All verified red against the pre-fix code and
green after, plus regression bundles (crud, properties, associations,
onmissingmethod, scopes, enums, query builder) on Lucee 7 + SQLite.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bpamiri and others added 28 commits June 13, 2026 03:31
…rror" (#3195)

The successfully-rendered congratulations/welcome page shown on a fresh
install rendered with <title>Wheels - Error</title>. It includes the shared
_header_simple.cfm, which was originally written for the error screen and
hardcoded that title. The first thing a new user saw in their browser tab
said "Error".

_header_simple.cfm now reads an optional request.wheels.simpleHeaderTitle
override into its <title> and falls back to "Wheels - Error" when unset, so
the error path (EventMethods.$runOnError, which sets no override) is
unchanged. congratulations.cfm sets the override to "Welcome to Wheels"
before the include. The title is routed through request scope so it survives
the $includeAndReturnOutput boundary the error page uses, and is emitted via
EncodeForHTML.

Adds simpleHeaderTitleSpec covering: default error title when no override,
the welcome override, and the empty-string fallback. Verified by reverting
the fix (welcome test fails) and reapplying (passes); full Lucee7+SQLite
core suite green (4522 pass / 0 fail / 0 error).

Fixes #3175

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#3197)

* docs(cli): add deprecation README freezing the legacy CommandBox wheels-cli module

The legacy CommandBox wheels-cli module (cli/src/) drifts from the canonical
LuCLI-era wheels CLI and mis-advertised itself as the official CLI on ForgeBox.
Add a prominent deprecation README explaining the module is frozen, that the
supported CLI ships via brew/scoop/apt/yum + 'wheels new', and pointing at the
install docs. Notes that cli/src/templates/ is still consumed by the LuCLI
module build, so the directory is not entirely dead code.

Refs #3180, #3184

Signed-off-by: Peter Amiri <peter@alurium.com>

* ci: stop re-publishing the deprecated CommandBox wheels-cli to ForgeBox

The release pipeline ran prepare-cli.sh on every stable/RC cut, packaging the
legacy CommandBox wheels-cli module (cli/src/) and publishing it to ForgeBox as
wheels-cli@<framework-version> (marked stable). That module does not know about
Wheels 4.0+ and produces divergent behavior from the canonical LuCLI wheels CLI
(e.g. leaking an unrendered {{enums}} placeholder into generated models), yet
its box.json/README advertised it as the official CLI.

Freeze it: remove the Prepare/Validate/Build/Upload/Publish wheels-cli legs from
release.yml and release-candidate.yml, drop the Wheels CLI leg from
publish-to-forgebox.sh, and rewrite tools/build/cli/{box.json,README.md} to lead
with the deprecation in case of any future manual republish. The base/core/
starter ForgeBox path and the canonical LuCLI module tarball build are untouched.
The already-published wheels-cli@4.0.3 is left in place (not unpublished).

Verified: both workflows parse (ruby -ryaml) and pass actionlint with no new
findings; no remaining workflow/script reference resolves the removed
build-wheels-cli/ output.

Refs #3180, #3184

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…geBox (#3199)

The published ForgeBox starter app was unusable end-to-end:

(a) `box install wheels-starter-app` FAILED. box.json declared a dependency
    on slug "wheels-authenticateThis" which does not exist on ForgeBox
    ("entry slug invalid or does not exist"), aborting the whole install.
    The authenticateThis() mechanism the app uses is the plugin already
    vendored under plugins/authenticateThis/ — the ForgeBox dependency was
    redundant. Drop the bogus dependency + installPath; keep wheels-core so
    the framework still lands in vendor/wheels/.

(b) Even when installed, the app booted HTTP 500 "key [DB_CLASS] doesn't
    exist" at config/app.cfm:14 — the datasource read this.env.DB_CLASS but
    no .env shipped or scaffolded (only .env.example). Replace the
    env-driven MySQL datasource with a zero-config H2 embedded database
    (org.h2.Driver, bundled with Lucee). The H2 Lucee extension is declared
    in server.json so `box server start` auto-installs it — no manual
    LUCEE_EXTENSIONS or .env needed. Add a starterApp_test datasource for the
    app test suite and a db/h2/ data directory.

.env is now optional and documented as such (server-based DB instructions
preserved in .env.example comments + README). README Quick Start rewritten
to the real flow: box install -> box server start -> wheels migrate latest.

Verified in CommandBox docker (ortussolutions/commandbox:latest) against the
prepare-starterApp.sh build output: box install succeeds (wheels-core only),
the server boots and the H2 datasource loads, `migrate latest` creates the
schema + seeds, and GET / returns HTTP 200 with the seeded site title.

Fixes #3181

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…3183) (#3198)

The v4-0-0 guides contained zero `box install` mentions, leaving
CommandBox-centric 2.x/3.x teams with no answer to "what is my supported
workflow now?" despite release.yml publishing four ForgeBox packages on
every stable release.

Add a new start-here page that states the support tier explicitly:
- Supported: install the framework via ForgeBox
  (`box install wheels-base-template` -> wheels-core in vendor/wheels/)
  and serve it (`box server start`, reload URL, engine pinning).
- Not supported via CommandBox: the `wheels` CLI feature set
  (generators, migrate, test, console, MCP, packages registry, deploy) —
  install the LuCLI `wheels` binary from brew/scoop/apt/yum for those.
- Legacy-slug note: `wheels-cli` ForgeBox module is deprecated
  (removal in v5); points at the 4.x slugs.
- 2.x/3.x box-workflow -> 4.0 mapping table.

Written to the campaign target end-state (assumes #3176/#3173 keystone
template fix and #3177 core box.json fix land — #3177 already on develop:
core box.json uses directory=vendor + packageDirectory=wheels, base
template installPaths puts wheels-core in vendor/wheels/).

Box command blocks are marked illustrative (untagged) per the {test:*}
policy — the verify-docs harness only runs the `wheels` binary, not
`box`, so they are correctly ignored. Adds the sidebar entry and
cross-links from installing.mdx and cfml-engines.mdx.

pnpm verify:docs on the page exits 0 (0 tagged blocks).

Fixes #3183.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
)

box publish derives the ForgeBox slug from box.json["slug"], not from any
argument the publish script passes. The 4.0 pipeline must only ever land on a
wheels-* slug; the deprecated 2.x slugs (cfwheels-base-template, 76k installs;
cfwheels-cli) are frozen and must never receive a 4.0 artifact.

The publish path is already safe by construction — every template box.json
carries a wheels-* slug and the prepare-*.sh scripts only substitute the
version placeholder — but a stray box.json edit or a future cfwheels-* shim
could regress that silently. Add a defensive allowlist assert in
publish_package() that reads the resolved slug and refuses anything that is
not wheels-* (and refuses a missing slug), failing the release loudly instead
of mispublishing to a legacy slug.

Refs #3182

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ath (#3202)

Durability net for the ForgeBox/CommandBox install path. The channel was
published but broke end to end, undetected for weeks, because nothing in CI
exercises the *built* packages the way a CommandBox user installs them.

This leg builds the base + core packages FROM THE TREE (prepare-base.sh +
prepare-core.sh), stages them as `box install wheels-base-template` lands them
(base at the app root, wheels-core at vendor/wheels/ per box.json installPaths),
boots under the ortussolutions/commandbox image, and asserts the install->serve
journey with ZERO manual edits:

  - dev:  server binds no-edit, GET / 200 (welcome), /wheels/info 200 (dev
          whitelist, #2988), clean URL /main/index 200, db/ ships, no
          placeholders survive into the artifact.
  - prod: tools/ci/smoke-env.sh passes all six reload-gate / no-trace probes.

Pre-boot structural guards name the three packaging defects the campaign
repaired (#3173 placeholders, #3174 db/, plus the root-view generation) so a
regression is reported precisely instead of as an opaque boot failure.

Gated on tools/build/**, cli/lucli/templates/app/**, tools/ci/smoke-env.sh, and
the workflow file itself.

This leg tests the FIXED pipeline: the template keystone (#3176) is a sibling
PR not yet on develop, so the leg goes green once #3176 merges. The wheels-core
fixes (#3177/#3178/#3179) are already on develop. Verified locally (CommandBox
6.3.3): keystone-composed tree passes all probes; unfixed develop artifact
exits 1 with "Invalid slug detected" — the gate is real, the probes are not
weakened.

Refs #3173, #3174, #3176, #3177
Closes #3201

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…tall boots clean (#3196)

* build(forgebox): rebuild base template from LuCLI template so box install boots clean

The published wheels-base-template ForgeBox package was broken end to end: a
raw `box install` produced an app that `box server start` could not boot, and
the artifact diverged from what `wheels new` produces.

Source the app/config/db/public/tests/vendor scaffold from the single source of
truth (cli/lucli/templates/app/) instead of the repo-root demo app, then layer
the CommandBox-only build artifacts (server.json, box.json, README.md, base
.gitignore, .mcp.json, .opencode.json) on top.

Fixes:
- #3173: substitute placeholders at BUILD TIME to working defaults
  (appName=Wheels, cfengine=lucee, datasourceName=wheels, reloadPassword empty).
  `box install` has no substitution step, so the |cfmlEngine| token previously
  killed `box server start` with "Invalid slug detected". The published artifact
  now carries zero |tokens| / {{tokens}}.
- #3174: the base .gitignore no longer excludes /db/** — db/ (with db/.keep)
  ships so JDBC drivers have a parent dir. Only generated *.sqlite/*.db files
  are ignored.
- #3176: sourcing from the LuCLI template auto-drops repo-only demo cruft
  (app/jobs/ProcessOrdersJob.cfc, public/ApplicationProxy.cfc, public/index.bxm,
  the dev /cli + /modules mappings in public/Application.cfc). The template's
  settings.cfm carries set(useUnderscoreReferenceColumns=true) and the current
  guides.wheels.dev links; README dependency corrected to wheels-core 4.0.3.

Generate the default Main controller + app/views/main/index.cfm in the build
exactly as `wheels new` does post-copy — without them the root route
(main##index) throws Wheels.ViewNotFound on GET /.

Verified end to end in CommandBox docker (CommandBox 6.3.3 / Lucee 7.0.4):
clean `box install` auto-pulls wheels-core to vendor/wheels, `box server start`
boots with zero manual edits, GET / -> 200 welcome page, /wheels/info and
/wheels/routes -> 200 in dev, clean URLs route (dropped Widgets controller
resolves; unknown route -> 404), db/ ships. tools/ci/smoke-env.sh reload-gate
probes pass (reload-unauthenticated and reload-wrong-password both refused);
the info-404 and trace-marker probes note dev-expected behavior (info page +
debugbar render only in development).

Fixes #3176, #3173, #3174

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(build): accept REPO_ROOT-prefixed LICENSE/NOTICE copy in prepare-base guard

The ForgeBox base-template rebuild (#3196) made prepare-base.sh
CWD-independent: it derives REPO_ROOT from BASH_SOURCE and reads every
source file by absolute path, including `cp "${REPO_ROOT}/LICENSE"
"${BUILD_DIR}/"`. The three sibling prepare scripts (core/cli/starterApp)
run from the repo root and keep the bare `cp LICENSE "${BUILD_DIR}/"`
form. buildArtifactLicenseSpec only matched the bare form, so the keystone
rebuild failed two assertions even though LICENSE/NOTICE still land in
BUILD_DIR.

Broaden both the LICENSE and NOTICE regexes to accept an optional
`"${REPO_ROOT}/` prefix while staying tight enough that deleting a copy
line still fails the guard (verified red->green: original spec 6 pass /
2 fail, fixed spec 8 pass / 0 fail). prepare-base.sh is left on the robust
absolute-path form on purpose — running it from an unrelated CWD still
produces a complete build (LICENSE, NOTICE, db/, no residual placeholders),
which a bare `cp LICENSE` would break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Peter Amiri <petera@pai.com>
…in injection (#3161)

Every controller instantiation, materialized model row, dispatcher
resolution and request start constructed a throwaway wheels.Plugins —
paying the full wheels.Global parent pseudo-constructor (including the
$promoteIncludedGlobalsToThis variables-scope scan) — only to call
$initializeMixins, which reads nothing from the instance.

- $initializeMixins scratch state (appKey/metaData/className) is now
  strictly local-scoped; previously the unscoped $wheels.* writes landed
  in the instance's own variables scope, a data race the moment one
  instance is shared (className cross-contaminates which mixin set a
  target receives). The trailing no-op StructDelete is removed.
- New Global.cfc $pluginObj() returns the application-cached
  application[appKey].PluginObj, falling back to a fresh instance during
  bootstrap windows or where the application scope is undefined.
- Swapped at the four request-lifecycle sites: Controller/Model/Dispatch
  onDIcomplete and EventMethods $runOnRequestStart. The EventMethods
  construction is also hoisted inside the mixins-nonempty guard, so
  mixin-free apps skip it entirely.
- Test.cfc and events/onapplicationstart.cfc keep constructing: the
  former can run where application is undefined, the latter runs once
  per app start inside the bootstrap window.

Spec: pluginsSharedInstanceSpec pins the mechanism — a Plugins-subclass
probe proves no scratch state leaks into the shared instance's variables
scope (red on the previous code), plus shared-instance classification and
$pluginObj cache/fallback behavior.

Refs #2897 (PR B / Stage 3)

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Peter Amiri <petera@pai.com>
… (#3167)

* feat(cli): deliver env.secret to containers via remote env file (#2957)

Implements the env.secret delivery feature #3008 deliberately deferred (its
EnvSecretUnsupported fail-fast pointed users at #2957). Kamal model:

- Values resolve through the SecretResolver the ConfigLoader already builds
  (new secretResolver() accessor); env-file content renders once per verb.
- The remote file is created and chmod'd 600 BEFORE content lands (mkdir +
  touch + chmod 600), then the content travels over SFTP via uploadString —
  values never enter argv, dry-run output, or exception command summaries.
- docker run references the file via --env-file
  (.kamal/apps/<service[-destination]>/env/roles/<role>.env; accessories use
  .../env/accessories/<name>.env).
- Wired into deploy(), app boot, and accessory boot/reboot. rollback/start
  reuse the env baked into the existing container.
- A declared name with no resolvable value throws
  Wheels.Deploy.EnvSecretMissing (missing names only, values never read)
  before the lock or any remote call. Base.$rejectEnvSecrets is removed.
- env.clear stays as escaped -e pairs; per-role env merge remains out of
  scope (#3088 note unchanged).

Specs: FakeSshPool ordering (ensure -> upload -> run), 600 perms in the
command, secret values absent from every command summary, dry-run redaction,
fail-fast with zero pool calls. CLI suite (lucee7 docker harness): 1004
pass / 0 fail / 2 tolerated docker-env artifacts. Real SSH delivery is
unverifiable in-harness; FakeSshPool + --dry-run flows are the bar.

Guides updated: deployment/secrets, config-reference, accessories,
first-deploy, migrating-from-kamal now describe delivery on 4.0.4+ builds
vs silent drop on released 4.0.3.

Refs #2957

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(cli): keep deploy env files at 600 perms through the SFTP upload

sshj's SFTPFileTransfer defaults preserveAttributes=true and
FileSystemFile.getPermissions() hardcodes 0644 for regular files, so
every SFTPClient.put() chmod'ed the remote file to 0644 right after the
ensure command had locked it to 600 — with the secret content inside
(verified against the bundled sshj-0.39.0 bytecode).

- SshClient.upload(): setPreserveAttributes(false) so an upload never
  touches remote permissions. FakeSshPool cannot regression-test this
  (it records calls without SFTP attribute semantics) — documented at
  the call site.
- $deliverEnvFile (all three mirrors): dispatch a relock command
  (chmod 600) after the upload as belt-and-braces; this leg IS pinned
  by the FakeSshPool specs (ensure -> upload -> relock -> docker run).
- New AppCommands.relock_env_file() / AccessoryCommands
  .relock_env_file() builders over Base.$relockEnvFileCmd().
- Docs/changelog updated to state the file is re-locked after upload.

CLI suite: 1006 pass / 0 fail / 2 errors (known docker-not-found
artifacts in SshClientSpec/SshPoolSpec inside the harness container).
Real-SSH SFTP behavior is unverifiable in-harness; the fake-pool specs
plus the dispatched relock are the testable bar.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
…won't re-fire (#3126)

* fix(cli): reload and packages add no longer claim onApplicationStart won't re-fire

An authorized ?reload=true&password=... calls applicationStop(), so the next
request re-fires onApplicationStart in full — config/services.cfm and the
PackageLoader ($loadPackages) all re-run (verified live on Lucee 7, #3110).

The reload command's note and the packages-install activation line claimed the
opposite, telling users to run `wheels stop && wheels start`. Correct both:
the reload note now describes the real full-restart behavior and surfaces the
password-resolution caveat (a missing/wrong password silently skips the
restart, #3059 / #3062); the install output now says `wheels reload` (or
restart) activates the package.

Tutorial/guide passages making the same claim are handled separately by
bot-update-docs.yml.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): correct reload contract — wheels reload re-fires onApplicationStart

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Peter Amiri <petera@pai.com>
… its metadata (#3163)

* fix(cli): make the deploy lock all-or-nothing across hosts and expand its metadata

DEP-1 (#2957): lock acquire/release went through $dispatchAny ->
SshPool.onAny, which is first-success-wins and swallows per-host
failures. On any multi-host fleet, contention on host 1 raised, onAny
caught it, acquired a fresh lock on host 2, and the concurrent deploy
proceeded — mutual exclusion held only for single-host configs. Release
was also onAny, so the released host could differ from the acquired
host, stranding stale locks.

Now the deploy lock is acquired on EVERY (deduped) host, sequentially
in config order with raise=true; on a partial failure the
already-acquired locks are rolled back (never the contended host's —
that lock belongs to the other deploy) and the per-host error surfaces
as Wheels.Deploy.LockAcquireFailed naming the failing host. Release
fans out to the exact hosts the lock was acquired on. The manual
'wheels deploy lock acquire/release/status' verbs follow the same
fleet-wide semantics. SshPool.onAny itself is unchanged — the proxy
boot check still legitimately uses it (Wave 2 scope).

DEP-10 (#2957): LockCommands.acquire wrapped the whole symlink target
in single quotes while claiming $(hostname)/$(date) were resolved by
the remote shell — single quotes suppress command substitution, so lock
metadata recorded the literal text. The target is now three
concatenated shell words: shellEscape(user) + a double-quoted
substitution segment + shellEscape(message), so the metadata expands
while hostile metacharacters in user/message stay inert.

Refs #2957 (Wave 1: DEP-1a, DEP-1b, DEP-10)

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(cli): per-host best-effort lock release so a dead host cannot strand the fleet

Review findings on the all-or-nothing lock PR: allowFail only mapped to
{raise: false} inside SshClient.run, which suppresses nonzero exit codes
but not transport failures.

- DeployLockCli release/status fanned out via SshPool.onEach, which
  pre-resolves a connection for every host before running anything, so
  one unreachable host aborted the whole verb with zero commands
  executed — turning the prescribed stale-lock recovery path into a
  dead-end exactly when a host died mid-deploy. Both verbs now dispatch
  per host with a per-host try/catch and report skipped hosts in the
  summary instead of throwing.

- DeployMainCli's finally-block release claimed it could never shadow
  the original deploy exception, but a transport failure (dead cached
  connection, startSession throws inside the onEach task, rethrown from
  future.get) propagated out of the finally and replaced the in-flight
  deploy error. The release is now per-host best-effort; skipped hosts
  are logged, never thrown, and every healthy host is still released.

- $rollbackAcquiredLocks in both CFCs is host-granular for the same
  reason: one dead host no longer stops the rollback from clearing the
  locks on the remaining healthy hosts.

- FakeSshPool now mirrors the real pool's transport semantics so specs
  can exercise these paths: failConnection(host) models the eager
  connect throw (onEach aborts wholesale, sequential fails lazily,
  onAny skips to the next host) and a scripted transportError result
  throws from run() regardless of raise=false. Verified the new specs
  fail against the previous production code.

Real SSH remains unverifiable in the harness — FakeSshPool mirrors the
verified real-pool semantics (SshPool.onEach pre-resolve, SshClient.run
transport throws) and the full CLI suite passes.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Peter Amiri <petera@pai.com>
…3169)

- DEP-11a: same-version redeploy — force-remove a conflicting same-name
  container before docker run (exact-anchored name filter, xargs -r
  idempotent) and stop superseded versions best-effort after the
  kamal-proxy cutover (AppCommands.remove_conflicting / stop_old_versions,
  wired into DeployMainCli.$deploy).
- DEP-11b: 'wheels deploy secrets extract' key match is now exact —
  CFML == is case-insensitive, so 'extract path' matched the PATH line.
- DEP-11c: kamal-proxy config volume derives the remote home from the
  ssh user (/root for the default root user) instead of hardcoding
  /home/<user>.
- SecretResolver: bounded stdout drain + deadline (default 60s,
  opts.timeoutSeconds) replaces the unbounded waitFor()/read-to-EOF —
  an interactively-blocking secrets command now kills bash and throws
  SecretResolver.ResolutionFailed with a clear message.
- rollback() forwards {destination} into ConfigLoader.load — it was the
  last verb not applying the --destination overlay (same class as 3085).

All five red-first specs plus full CLI suite green in the lucee7 docker
harness (1015 pass / 0 fail; 2 known docker-env artifacts).

Refs #2957

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
…g can work (#3141)

Since #2082 the ?reload=<environment> switch requires a non-empty
reloadPassword plus a matching password parameter (and is gated by
allowEnvironmentSwitchViaUrl), but the debug bar still rendered the
Testing/Maintenance/Production quick-switch anchors only when NO
reloadPassword was set — the exact configuration where switching is
impossible. Clicking one restarted the app and silently stayed in the
current environment.

The Environment panel now renders the links only when a reloadPassword
is configured AND allowEnvironmentSwitchViaUrl resolves true. The links
never embed the password: a new wdbEnvSwitch() handler prompts for it
at click time and issues the documented
?reload=<environment>&password=... request. The plain ?reload=true
reload anchor keeps its existing no-password gate.

Guides (debug-panel.mdx, environments-and-configuration.mdx) updated to
describe the working behavior instead of the dead-link caveat.

Fixes #3060

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Peter Amiri <petera@pai.com>
…3203)

Fable 5 was deactivated (2026-06); the bot reviewer (bot-review.yml +
bot-review-fork.yml) was the only surface still pinned to it. Repoint
both --model flags to claude-opus-4-8 and update the model-policy
comments + .ai/wheels/wheels-bot.md prose to match (judging gate is now
Opus 4.8). Coding stages were already opus-4-8; janitorial stays sonnet.

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

#3008 added a $rejectEnvSecrets guard that hard-failed any non-empty
env.secret with Wheels.Deploy.EnvSecretUnsupported, which made the
env.secret block scaffolded by `wheels deploy init` (WHEELS_RELOAD_PASSWORD)
un-deployable without manual editing. #3167 then retired that guard and
implemented env.secret delivery via a remote --env-file (600 perms, SFTP),
so the scaffolded block is now correct and deploys end-to-end.

This pins that contract with two DeployMainCliSpec regression tests:

- the init scaffold round-trips through config() and deploy --dry-run with
  no EnvSecretUnsupported/EnvSecretMissing, and the dry-run routes the
  secret through the --env-file path;
- a deploy of the scaffold delivers WHEELS_RELOAD_PASSWORD to the role env
  file over SFTP (FakeSshPool uploadString), never in argv.

Both tests fail if the scaffold drops its env.secret block (verified by
temporarily removing it), so they guard the scaffold and the deploy engine
against drifting apart again. No template or engine change is needed: the
scaffold is correct as shipped now that env.secret is a delivered feature.

CLI suite (lucee7 docker harness): 1071 pass / 0 fail / 2 tolerated
docker-env artifacts (SshClientSpec/SshPoolSpec require docker-in-docker).

Refs #3008, #3167
Fixes #3158

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
…ed summaries (#3205)

env.clear values interpolated from ${SECRET} tokens in .kamal/secrets ride
`docker run ... -e KEY=value`, so a nonzero remote exit surfaced the raw
secret in the Wheels.Deploy.RemoteExecutionFailed message and CI logs
(deferred from #3008).

SshClient.$raiseRemoteFailure (the byte-identical FakeSshPool mirror too) now
scrubs every occurrence of each registered secret value to [REDACTED] BEFORE
the 200-char trim, so a value on the boundary can't leak a partial fragment.
Empty and trivially short (<4 char) values are skipped so unrelated command
text is never mangled. $setSecretValues registers the resolver's values;
DeployAppCli/DeployMainCli wire it from ConfigLoader.secretResolver().all()
after each load, ahead of any dispatch.

Covered via FakeSshPool (redaction, multi-occurrence, no-secret, empty/short,
boundary), a SshClient mirror-parity spec (runs without sshd), and an
end-to-end DeployAppCli integration test (config -> resolver -> failed run).

Fixes #3159

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
…erb (#3206)

The #3159 fix wired $registerSecretsForRedaction() into DeployAppCli and
DeployMainCli, but the standalone `wheels deploy accessory boot|reboot <name>`
verbs route through DeployAccessoryCli, which shares no base class and never
registered the resolver's secret values on its SshPool. AccessoryCommands
embeds env.clear values — including ${SECRET}-interpolated ones — as
`docker run ... -e 'KEY=value'` in argv, so a nonzero accessory exit surfaced
the raw secret in the Wheels.Deploy.RemoteExecutionFailed message and CI logs:
the exact leak #3159 closes, just on the accessory verb.

DeployAccessoryCli now calls $registerSecretsForRedaction() in $forEach right
after load() (ahead of any $dispatch), reading loader.secretResolver().all()
the same way the other two CLIs do. A regression spec drives the leak path
end-to-end: an env.clear value interpolated from a ${DB_ROOT_PW} token, a
failed accessory docker run, and asserts the value is scrubbed to [REDACTED].

Refs #3159

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
The stdio MCP surface advertises tools by their bare function name
(`packages`), not a `wheels_`-prefixed form — live-verified via
`wheels mcp wheels --once tools/list`. The packages guide was the last
v4-0-0 doc still calling it `wheels_packages`; the guide table, the
mcp() help text, and CLAUDE.md were already corrected on develop.

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ebug-bar slimming (#3210)

* perf: cold-start warm-up, O(1) protected-method gate, schema cache, debug-bar slimming

Profiling-driven optimizations. JFR + ab profiling showed cold start is ~85%
Lucee CFML->bytecode compilation (bootstrap logic is only ~20ms) while warm
serving is already ~0.4ms; these target the cold path and a couple of clean
hot-path/dev wins, none changing public APIs.

- Dispatch gate (controller/processing.cfc): test the protected-helper set with
  an O(1) StructKeyExists lookup instead of an O(n) ListFindNoCase scan. A
  companion application.wheels.protectedControllerMethodsLookup struct-as-set is
  built once at app start alongside the retained comma-list; case-insensitive
  matching is unchanged.
- Column metadata (databaseAdapters/Base.cfc): memoize cfdbinfo "columns" per
  datasource+table in application.wheels.cache.schema when cacheDatabaseSchema
  is on. Rebuilt on reload, so schema changes are still picked up on reload.
- Dev debug bar (events/onrequestend/debug.cfm): externalize the static CSS/JS
  to vendor/wheels/public/assets/{css,js}/debugbar.* (eliminating the CFML
  ##-escaping footgun) and collapse inter-tag whitespace. Dev-only; production
  unaffected.
- Scaffold (cli templates): ship a /up liveness/warm-up endpoint that
  `wheels deploy`'s healthcheck probes before cutover, plus production-config
  warm-up recipe and inspectTemplate=never guidance.

Signed-off-by: Peter Amiri <petera@pai.com>

* docs(changelog): reference PR #3210 in perf fragments

Signed-off-by: Peter Amiri <petera@pai.com>

* chore(web): refresh visual baseline(s) (all)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml
on branch peter/perf-cold-start-serving-followups.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

* fix(model): move schema column cache out of the time-based cache namespace

Addresses the bot reviewer on #3210. The column-metadata cache stored raw
query objects under application.wheels.cache.schema, but every
application.wheels.cache.* category is owned by the time-based cull/count
machinery: $cacheCount() sums StructCount across all categories toward
maximumItemsToCache, and the cull dereferences `.expiresAt` on every entry
with no type guard (Global.cfc:904). A raw query has no expiresAt, so once the
global cache filled (page/partial/query caching, on by default in production)
and a cull was due, it would throw when it reached a schema entry.

- Move the cache to a sibling top-level key application.wheels.schemaColumnCache
  (set in onapplicationstart next to, not under, cache) so the cull/count never
  walk it. Matches the stated intent: persists for the app lifetime, rebuilt on
  reload.
- Mirror $getFromCache's defensive shape: wrap the read in try/catch and
  Duplicate() the cached query on store and on return, so a concurrent struct
  read can't surface a partial value and no caller can mutate the cached query
  in place (the first review's non-blocking note).
- Update schemaColumnCacheSpec and the changelog fragment for the new key.

Signed-off-by: Peter Amiri <petera@pai.com>

---------

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…skip ci]

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…3216)

The "second piece of drift" paragraph claimed, present tense, that the
deprecated /wheels/mcp endpoint "still" advertised the phantom
mcp-configuration-guide.md path and that aligning it was "its own follow-up".
That follow-up has since shipped (the 4.0.x audit sweep): both
vendor/wheels/public/views/mcp.cfm and the hand-written McpServer.cfc point to
the live guide, and McpDeprecationNoticeStaleDocPathSpec guards against the
phantom path returning. Reframe the paragraph so a reader today sees the loop
is closed. Exported from the publishing admin (post #1057).

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
* fix(plugin): make the deprecated plugins/ directory optional

The legacy plugins/ directory is superseded by vendor/<name>/ packages and
apps are expected to remove it. But two code paths still listed it
unconditionally and threw when it was absent on engines whose directory
listing errors on a missing path (e.g. RustCFML; Lucee/Adobe return empty):

- The scaffold's public/Application.cfc jar-scan (this.javaSettings.LoadPaths
  loop) — guarded with DirectoryExists; mirrored into the demo app and the
  tweet/starter-app examples.
- The framework plugin loader Plugins.cfc $folders()/$files() — now
  short-circuit to an empty query when the plugins directory does not exist.

Behavior is unchanged when plugins/ exists (the scan runs as before); when it
is absent, no plugins load and no error is raised. Adds pluginsMissingDirSpec
(init + $folders()/$files() against a non-existent path). The lookup is
deprecated and slated for removal in the next major.

Signed-off-by: Peter Amiri <petera@pai.com>

* test(plugin): exercise the missing-dir guard via the $pluginObj helper

The spec called $pluginObj(config) without defining the helper the four
sibling plugin specs use, so it resolved to the parameterless
Global.$pluginObj() that WheelsTest auto-binds — which ignores config and
returns the cached PluginObj pointing at the real plugins/ dir. The
missing-path branch (the $folders()/$files() DirectoryExists guards, the
actual fix) was never executed; the assertions passed for the wrong reason.

Add the same component-level $pluginObj helper the siblings use so
$createObjectFromRoot dispatches $init with pluginPath=missingPath,
building a Plugins instance bound to the non-existent path. The three
assertions now genuinely exercise the guard.

Addresses wheels-bot review on #3211.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

---------

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#3212)

* fix(mapper): cross-engine null-safety for BoxLang strict null handling

BoxLang throws a NullPointerException on a null string/struct subject where
Lucee and Adobe coerce null to ""/empty. Surfaced benchmarking the framework on
BoxLang via the perf bench app.

- mapper/matching.cfc: normalize a null derived `pattern` to "" after the
  pattern/name guard. A named route given via `to=` with no explicit pattern
  (or some resource forms) left `pattern` null at the Find/ReFindNoCase/concat
  sites, failing to load on BoxLang at onApplicationStart.
- Dispatch.cfc $buildMiddlewareCgiScope(): default null request headers to {}
  before iterating, so a null from the servlet host doesn't NPE the CGI build.
- events/EventMethods.cfc $runOnError(): guard a null exception `cause` before
  StructKeyExists so the error handler itself doesn't NPE.

Adds mapperNullPatternSpec covering the named-`to=` and resources() route shapes
the compat-matrix demo app (.wildcard().root() only) left unexercised on
BoxLang. Behavior is unchanged on Lucee/Adobe (null already coerced); full core
suite green (4539). Note: this fixes route loading + these request-path sites;
BoxLang's live request path has further null-handling spots tracked separately.

Signed-off-by: Peter Amiri <petera@pai.com>

* fix(mapper): derive named-route pattern on BoxLang instead of emptying it

Addresses the bot review on #3212. The previous normalization set a null
`pattern` to "" AFTER the name->pattern derivation, so on BoxLang a named route
given via to= (e.g. .get(name="ping", to="main##ping")) got an EMPTY pattern
instead of the hyphenized name every other engine produces — trading the NPE
for a silent cross-engine divergence that breaks URL matching and linkTo().

Root cause: on BoxLang an unpassed optional `pattern` arg is a present-but-null
key, so StructKeyExists() is true and the hyphenize() derivation is skipped.
Fix: strip the present-null `pattern` key BEFORE the derivation block, so the
existing hyphenize() path runs identically on every engine and the
"pattern or name required" guard still fires when neither is supplied.

Strengthen the spec (now PascalCase MapperNullPatternSpec) to assert the
derived pattern value (r[1].pattern == "/ping") mirroring MatchingSpec, so an
empty-pattern regression fails on the BoxLang matrix rather than passing.
Full core suite green (4539).

Signed-off-by: Peter Amiri <petera@pai.com>

* fix(events): guard present-null rootCause in $runOnError; correct changelog

Address wheels-bot review on #3212:

- EventMethods.cfc $runOnError dereferenced
  arguments.exception.cause.rootCause.type after only guarding `cause`,
  so a present-but-null `rootCause` (BoxLang strict null semantics) still
  NPE'd — the same null-deref class this PR hardens (reproduced on real
  BoxLang 1.11.0). Add symmetric !IsNull + IsStruct +
  StructKeyExists(...,"type") guards before the deref in both branches.

- The changelog fragment described the OLD, superseded approach
  ("normalizes a null derived pattern to ''"). Rewrite it to match the
  shipped code: matching.cfc removes the present-null `pattern` key
  before derivation so a named route derives its pattern from its name.
  Also reflect the new rootCause guard and the PascalCase
  MapperNullPatternSpec filename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

---------

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#3209)

* fix(model): resolve hasMany shortcut name in include expansion (#3208)

A hasMany shortcut name (e.g. shortcut="Category") is registered as a
dynamic accessor method, not a first-class association. Passing it to
findAll(include=...) fell through include expansion unchanged and then
threw Wheels.AssociationNotFound.

$expandThroughAssociations now resolves an include name that is not a
this-model association but matches a hasMany shortcut into the nested
bridge include (<assocName>(<ListFirst(through)>)), so the join through
the bridge model happens as expected. The issue #3109 contract is
preserved: real association names never enter the shortcut branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): note shortcut name accepted in findAll include for many-to-many

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore(web): refresh visual baseline(s) (all)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml
on branch fix/bot-3208-hasmany-shortcut-association-not-recognised-by-inc.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#3219)

* fix(build): changelog-promote emits release.yml-compatible section format

The changelog.d fragment system (#2994) was never exercised by a real cut.
Its promote step wrote "## [version] - date" (two-hash, no tag link), but
every released section — and release.yml's notes extraction
(awk '/^# \[VERSION\]/,/^---$/') — uses a single-hash "# [version](tag) => date".
With the two-hash header the one-hash awk extracts zero lines, so the GitHub
Release would publish with EMPTY notes.

Also: boundary detection searched for the next "## " heading, but version
sections here are single-hash, so the first "## " match landed on a
subheading inside the 3.0.0 section — pulling every release since into the
promoted body. And a stray "----"/"---" rule in [Unreleased] could leak in.

- Match the next version heading at "#" OR "##" + " [" (correct tail boundary).
- Drop horizontal-rule lines from the promoted body.
- Emit "# [version](tag-url) => date" + explicit "---" separators around it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* chore(release): assemble changelog.d fragments into [4.0.4]

Promote the 83 pending changelog.d fragments (plus prior [Unreleased] content)
into the [4.0.4] release section via tools/changelog-promote.sh, and clear the
fragment folder. Done on develop first (per the release playbook) so develop's
[Unreleased] doesn't go stale and force a back-port after the cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* fix(build): consolidate changelog sections by heading in canonical order

The first promote pass exposed a second defect: when [Unreleased] carries the
same heading twice (develop had two '### Performance' blocks), the merge
appended the fragment bullets to EACH same-named section — double-counting every
performance fragment (e.g. the $getStatusCodes entry appeared twice). It also
emitted sections in [Unreleased] file order with the duplicate intact and
fragment-only sections tacked on last.

merged_sections now combines all bullets for a heading into ONE section and
emits sections in canonical order (Added, Changed, Deprecated, Removed,
Performance, Fixed, Security), existing bullets before fragment bullets.

Regenerated the [4.0.4] section: 116 unique entries (was 124 with 8 dupes),
canonical order, no duplicate headings. Verified zero unique entries lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* docs(build): sync changelog-promote usage header + summary to new format

Address the Reviewer-A nit: the top-of-file usage comment still advertised the
old "## [version] - date" shape and the summary print() used " - " — both now
reflect the single-hash, tag-linked, "=>"-dated section the script emits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* test(build): cover new changelog-promote format + section consolidation

Address Reviewer-A (CHANGES_REQUESTED): the format change broke Test 3, which
still asserted the old "## [ver] - date" header — re-anchor it to the
single-hash, tag-linked, "=>"-dated section the script now emits. Add Test 5
covering the merged_sections consolidation: a [Unreleased] body with two
"### Performance" blocks plus a matching *.performance.md fragment must collapse
to one heading in canonical order with each bullet appearing exactly once (the
double-count regression the 4.0.4 cut surfaced). Full suite green (11/11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

---------

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bpamiri bpamiri merged commit 7cd32a7 into main Jun 19, 2026
2 of 3 checks passed
@bpamiri bpamiri deleted the release/4.0.4-to-main branch June 19, 2026 01:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant