Release 4.0.4#3220
Merged
Merged
Conversation
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 emptygit 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-releasetagsv4.0.4at the merge SHA → dispatches to homebrew/scoop/apt/yum + the develop auto-bump.4.0.4 scope (116 changelog entries)
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-closedwheels jobs work/statusCLI, changelog.d fragment system, ArgSpec MCP input schemas, deploy env-secret delivery + warmup endpoint,subpathsetting,upgrade --applyscope/namespacecallbacks, named capture groups), CORS global-middleware arbitration, RateLimiter memory/db stores, dispatch bare-cfaborton Adobe,tableName()getter guard, migrate info/doctor read-side fallbacks, CLI exit codes, BoxLang null-safety, and moreFull notes: the
# [4.0.4]section inCHANGELOG.md(extracted into the GitHub Release byrelease.yml).