- #csrfMetaTags()#
-
-
-
-
-
-// AJAX requests with CSRF token
-
-
-// Manual token generation
-component extends="Controller" {
- function config() {
- protectsFromForgery();
- }
-
- function create() {
- // Token automatically verified
- user = model("User").create(params.user);
- // ... rest of action
- }
-
- function apiEndpoint() {
- // Custom token validation
- if (!isValidToken(params.token)) {
- renderWith(data={error="Invalid token"}, status=403);
- return;
- }
- // ... api logic
- }
-
- private function isValidToken(required string token) {
- return arguments.token == authenticityToken();
- }
-}
-```
-
-## Usage
-1. Add `protectsFromForgery()` in controller's `config()` method
-2. Include `#csrfMetaTags()#` in layout head section
-3. Add authenticity tokens to all forms manually or automatically
-4. Configure AJAX requests to include CSRF token in headers
-5. Handle CSRF failures with appropriate error responses
-
-## Related
-- [HTTPS Detection](./https-detection.md)
-- [Authentication Patterns](../patterns/authentication.md)
-- [Form Helpers](../views/helpers/forms.md)
-
-## Important Notes
-- CSRF protection is disabled by default - must be explicitly enabled
-- Only protects POST, PUT, PATCH, DELETE requests (not GET)
-- API endpoints may need custom token handling
-- Test with both successful and failed CSRF validation
-- Use HTTPS in production for token security
-- Tokens are session-specific and expire with session
-
-## Configuration Options
-- `with="exception"` - Throws CSRF exception on failure (default)
-- `with="redirect"` - Redirects to specified route on failure
-- `only="action1,action2"` - Protect only specified actions
-- `except="action1,action2"` - Exclude specified actions from protection
-
-## Security Best Practices
-- Always enable CSRF protection for state-changing operations
-- Use HTTPS to prevent token interception
-- Don't include tokens in GET request URLs
-- Regenerate tokens on authentication state changes
-- Log CSRF violations for security monitoring
\ No newline at end of file
diff --git a/docs/superpowers/plans/2026-04-21-guides-rewrite-phase-2c-report.md b/docs/superpowers/plans/2026-04-21-guides-rewrite-phase-2c-report.md
new file mode 100644
index 0000000000..cc16d3f48b
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-21-guides-rewrite-phase-2c-report.md
@@ -0,0 +1,112 @@
+# Phase 2c Implementation Report
+
+**Plan:** `2026-04-21-guides-rewrite-phase-2c.md`
+**Branch:** `claude/upbeat-napier-7ccf97`
+**PR:** (to be opened)
+**Completion date:** 2026-04-21
+
+---
+
+## Summary
+
+14 content pages + sidebar wiring + scoped `.ai/` audit. Closes the gap flagged in the post-merge correction on PR #2169, where the PR body described Phase 2c content that had shipped as placeholder stubs only. All 14 pages source-verified against framework CFCs, middleware, CHANGELOG, and commitlint config. 16 total commits on this phase.
+
+**Deliverables:**
+- 6 Deployment & Operations pages: index (rewrite), production-config, docker-deployment, vm-deployment, security-hardening, observability-and-logging
+- 4 Contributing & Project pages: index (rewrite), pull-requests, coding-standards, writing-docs
+- 3 Upgrading pages: index (rewrite), 3x-to-4x, 2x-to-3x
+- 1 Glossary rewrite (45 alphabetized entries)
+- 1 sidebar update wiring all 13 new pages into three previously-empty groups
+- 2 `.ai/` files deleted (`.ai/wheels/configuration/security.md`, `.ai/wheels/security/csrf-protection.md`)
+
+**Final build:** 340 pages. `pnpm build` green. `pnpm verify:docs` on the Phase 2c diff: 43 tagged blocks across 14 files, all passing.
+
+**Kamal posture (user-confirmed 2026-04-21):** Deployment docs teach user-authored Dockerfiles + nginx/systemd — no fabricated `wheels docker`/`wheels deploy` commands. A single paragraph in `deployment/index.mdx` notes the Kamal port is in active development and links [kamal-deploy.org](https://kamal-deploy.org/) with a grep-able `TODO(phase-2c)` JSX comment for the future swap to a PR/issue URL.
+
+---
+
+## Commit log (16 commits)
+
+| # | SHA | Task | Page / action |
+|---|-----|------|---------------|
+| 1 | d84abb424 | 0 | Phase 2c plan + scope decisions |
+| 2 | 6ba558485 | 1 | deployment/index |
+| 3 | fb2ae57ed | 2 | deployment/production-config |
+| 4 | 27ec07009 | 3 | deployment/docker-deployment |
+| 5 | 79d99dfd8 | 4 | deployment/vm-deployment |
+| 6 | 51a13eb42 | 5 | deployment/security-hardening |
+| 7 | 4f5a2236d | 6 | deployment/observability-and-logging |
+| 8 | 6f0379677 | 7 | contributing/index |
+| 9 | 335c0203a | 8 | contributing/pull-requests |
+| 10 | a10d603be | 9 | contributing/coding-standards |
+| 11 | edc8fad60 | 10 | contributing/writing-docs |
+| 12 | da002e4b3 | 11 | upgrading/index |
+| 13 | 041e5d045 | 12 | upgrading/3x-to-4x |
+| 14 | 1c6716b17 | 13 | upgrading/2x-to-3x |
+| 15 | ac502c495 | 14 | glossary |
+| 16 | 395bcfaa7 | 16 | sidebar wiring |
+| 17 | 4d1b104aa | 15 | .ai/ audit |
+
+---
+
+## Drift caught during source verification
+
+Per-page drift caught by subagents comparing claims against authoritative source. Each entry = a claim that was *almost* shipped before source-verification caught it.
+
+| Page | Drift caught |
+|------|-------------|
+| `deployment/index.mdx` | Kamal implementation plan path `docs/superpowers/plans/2026-04-20-wheels-deploy-kamal-port.md` is on an unmerged worktree branch, not `develop`. Link replaced with [kamal-deploy.org](https://kamal-deploy.org/) + grep-able TODO comment. |
+| `deployment/docker-deployment.mdx` | Confirmed via grep that `wheels docker *` commands do NOT exist in `cli/lucli/Module.cfc`. Page explicitly tells readers to use plain `docker` / `docker compose` CLI. |
+| `deployment/docker-deployment.mdx` | `tools/docker/lucee7/Dockerfile` uses CommandBox-based `ortussolutions/commandbox:latest` — wrong for production users. Page authored a user-app Dockerfile from scratch using `lucee/lucee:7-tomcat10-jre21`. |
+| `deployment/production-config.mdx` | `application.wo.env()` env-var resolution order (`.env` → JVM `System.getenv`) confirmed at `vendor/wheels/Global.cfc:410-422`. Production auto-flips (`showErrorInformation=false`, `caching`, `autoMigrateDatabase=false`) anchored to specific framework init files. |
+| `deployment/security-hardening.mdx` | Every `SecurityHeaders` default + config option cited to exact line in `vendor/wheels/middleware/SecurityHeaders.cfc`. HSTS off-switch flagged as missing (issue #2174) rather than fabricating a non-existent parameter. |
+| `deployment/observability-and-logging.mdx` | Sentry package `packages/sentry/Sentry.cfc:36–45` catches controller exceptions via `sentryCapture` mixin but does NOT catch job failures (`vendor/wheels/Job.cfc:345, 368` only writes to `wheels_jobs` log). Gap documented in-page rather than assumed covered. |
+| `contributing/pull-requests.mdx` | Commitlint config (`commitlint.config.js`) sourced directly. Scope enum is 23 entries. Subject rule only rejects `upper-case` — CLAUDE.md's "lowercase subject" convention is documented as project convention rather than a hard commitlint gate. |
+| `contributing/coding-standards.mdx` | All 7 cross-engine rules cited to specific lines in `.ai/wheels/cross-engine-compatibility.md`. Mixin `private` → `$` pattern cited to lines 128-138. |
+| `contributing/writing-docs.mdx` | `{test:*}` directives sourced from `scripts/verify-docs/VALIDATION.md` line anchors. No invented directive syntax. |
+| `upgrading/index.mdx` | Framework version `4.0.0` sourced from `vendor/wheels/events/onapplicationstart.cfc:85`. Noted `vendor/wheels/Wheels.cfc` does not exist (the plan assumed it did) — subagent found the real location. |
+| `upgrading/3x-to-4x.mdx` | Every breaking change cites a CHANGELOG + PR number. Subagent flagged three unverified claims from the blog skeleton (`legacyCompatibilityAdapter` settings flag, `wheels browser install` upgrade step, `wheels doctor` existence). I grep-verified `wheels doctor` exists at `cli/lucli/Module.cfc:1212`; the other two were dropped or softened before commit. |
+| `upgrading/2x-to-3x.mdx` | v3.0 guides path `/v3-0-0/upgrading/3-0-0-config-migration/` verified against the Starlight slugifier (dots→dashes default) before linking. |
+| `glossary.mdx` | 3 terms flagged (Verify/verifies(), Strong params, Composite key) appeared in Phase 2a/2b pages but lacked a conceptual definition anchor. Excluded from glossary rather than invented. |
+
+---
+
+## Source-verification wins
+
+- **Eliminated fabricated `wheels docker`/`wheels deploy` commands** that legacy GitBook docs had documented — neither exists in v4.
+- **Every `SecurityHeaders` default** now cites the exact line of `SecurityHeaders.cfc` that sets it, so future drift is catchable.
+- **HSTS off-switch gap** (#2174) surfaced as a documented limitation rather than a fabricated parameter — matches the pattern Phase 2b-advanced established for known carryover.
+- **Sentry job-error coverage gap** documented in-page, preventing readers from assuming full job-failure instrumentation.
+- **Commitlint config** sourced directly, not via CLAUDE.md paraphrase.
+- **Cross-engine rules** cited to `.ai/wheels/cross-engine-compatibility.md` line anchors — this was the authoritative source all along.
+
+---
+
+## Carryover (non-blocking)
+
+1. **Kamal plan merge.** The `wheels deploy` implementation plan + design spec are on `claude/interesting-cartwright-ed6357`, not merged to develop. Once merged (or once a tracking issue/PR exists), swap the `deployment/index.mdx` TODO comment for a concrete link.
+2. **CODEOWNERS / MAINTAINERS file.** Core team is inferred from git log (Peter Amiri, Zain Ul Abideen). A MAINTAINERS.md or CODEOWNERS file would make `contributing/index.mdx` self-verifying. Recommend separate small PR.
+3. **HSTS off-switch (#2174).** `security-hardening.mdx` documents the gap; closing the framework issue lets us remove the caution note.
+4. **Auto-glossary linker.** `glossary.mdx` is hand-curated. A later polish task could auto-link glossary terms from other guides on first mention.
+5. **`.ai/` full sweep.** Scoped audit completed. Full `.ai/wheels/**` sweep is on the design spec's end-of-Phase-2 list (open question #3).
+6. **Upgrading from 3x → 4x blog skeleton reconciliation.** Subagent flagged two items that couldn't be verified against current source (`legacyCompatibilityAdapter` as a settings flag, parallel-runner as must-do). The blog skeleton may need updating to match the shipped 3x-to-4x page.
+
+---
+
+## Exit criteria check
+
+- [x] 14 content pages (6 Deployment + 4 Contributing + 3 Upgrading + 1 Glossary)
+- [x] `pnpm verify:docs` passes on the Phase 2c diff (43/43 tagged blocks)
+- [x] `pnpm build` — 340 pages, no broken-link warnings
+- [x] Sidebar JSON has no empty `items: []` under v4-0-0-snapshot
+- [x] Phase 2c report committed (this file)
+- [x] `.ai/` audit committed
+- [ ] Final review via `pr-review-toolkit:code-reviewer` (Task 18 — next)
+- [ ] PR opened to `develop` with accurate description
+
+---
+
+## Next
+
+- Task 18: Final code review via `pr-review-toolkit:code-reviewer` across the Phase 2c diff
+- Open PR to `develop` once review findings are resolved
diff --git a/docs/superpowers/plans/2026-04-21-guides-rewrite-phase-2c.md b/docs/superpowers/plans/2026-04-21-guides-rewrite-phase-2c.md
new file mode 100644
index 0000000000..9e3e09b91c
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-21-guides-rewrite-phase-2c.md
@@ -0,0 +1,243 @@
+# Wheels 4.0 Guides — Phase 2c Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship the four sections that PR #2169 claimed but didn't deliver: Deployment & Operations, Contributing & Project, Upgrading, Glossary. Plus sidebar wiring and the `.ai/` audit. Closes the gap flagged in the post-merge correction on that PR's description.
+
+**Architecture:** Per-page subagent authoring against authoritative sources. Each page cites source (Module.cfc line numbers, middleware CFC paths, upstream docs) so drift is caught at review. Where v4 has no framework-provided surface (notably `wheels docker` / `wheels deploy` — neither exists in v4), pages teach the underlying mechanics directly (Dockerfiles, systemd units, nginx config) rather than inventing commands.
+
+**Tech Stack:** Astro 5 + Starlight 0.34 + MDX. `wheels` CLI v0.3.5-SNAPSHOT+ (framework v4.0.0). Verify-docs harness with `{test:compile}` and `{test:cli}`.
+
+**Base:** New branch `peter/guides-rewrite-phase-2c` off `develop` after cc2050c39. Merges to `develop` in a single PR at end of phase.
+
+**Review model (unchanged from prior phases):**
+- Content pages — subagent-driven; harness + build as verification gate
+- Integration tasks (sidebar, `.ai/` audit, landing pages) — inline
+- End-of-phase final review — single `pr-review-toolkit:code-reviewer` subagent across the Phase 2c diff
+
+---
+
+## Prologue — scope decisions for this phase
+
+1. **Kamal posture (option a, user-confirmed 2026-04-21).** Deployment section documents Docker packaging + VM deploy + security hardening + observability using framework primitives that exist today. A single sentence in `deployment/index.mdx` notes the `wheels deploy` Kamal port is in progress with a link to the implementation plan at `docs/superpowers/plans/2026-04-20-wheels-deploy-kamal-port.md`. No `kamal-preview.mdx` page.
+2. **No CommandBox-era command docs.** The legacy GitBook pages at `docs/src/working-with-wheels/` and the deleted `docs/src/command-line-tools/commands/docker/*` describe a defunct CommandBox module. They may be skimmed for IA hints but are NOT authoritative for v4. Subagents must verify every command against `cli/lucli/Module.cfc` + LuCLI Java sources before documenting it.
+3. **User-authored Dockerfiles.** Since `wheels docker build/push/deploy` doesn't exist, `deployment/docker-deployment.mdx` walks the reader through writing a production Dockerfile for a Wheels app. The `tools/docker/lucee7/Dockerfile` in this repo is a cross-engine test rig, NOT a user template — do not copy/paste it; reference it only as an example of Java 21 + Lucee setup. The page ships a minimal reference Dockerfile that subagents author against Lucee 7 + Wheels 4.0 actuals.
+4. **Upgrading scope.** Two active upgrade paths (3.x → 4.x, 2.x → 3.x). The 3.x → 4.x page is the critical one: it's the entry point for existing Wheels users landing on v4. The 2.x → 3.x page can cite the v3.0 guides instead of re-documenting everything.
+5. **Contributing framing.** "Contributing & Project" section explicitly includes a "Writing docs" page (spec open-question #8, resolved in favor of inclusion). This teaches MDX + `{test:*}` harness + style guide so future contributors can fix/add pages without tribal knowledge.
+6. **Glossary is a reference.** Single `glossary.mdx` under `v4-0-0-snapshot/` (already exists as placeholder). Hand-authored from terms already in-use in shipped Phase 2a/2b pages. No auto-linker plumbing in Phase 2c — that's a later polish pass.
+7. **`.ai/` audit.** Remaining `.ai/wheels/**` files get checked for supersession by Phase 2c content. Any contributing/upgrading/deployment content in `.ai/` moves to guides or is deleted.
+
+---
+
+## File Structure
+
+### New files — Deployment & Operations (6 pages)
+
+All under `web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/`:
+
+| Path | Responsibility |
+|------|----------------|
+| `index.mdx` (rewrite) | Landing — deployment models overview, decision matrix (Docker vs VM), note `wheels deploy` Kamal port in progress, CardGrid to sub-pages |
+| `production-config.mdx` | Environment vars (`WHEELS_*`), `reloadPassword` rotation, datasource config for prod, route cache, environment.cfm for production |
+| `docker-deployment.mdx` | Writing a production Dockerfile (Lucee 7 + Java 21 + Wheels), docker-compose example, multi-stage build, image size tips, health checks |
+| `vm-deployment.mdx` | Lucee server install, nginx reverse proxy, systemd unit, zero-downtime with symlink swap, log rotation |
+| `security-hardening.mdx` | `SecurityHeaders` middleware config, CSRF enforcement, HTTPS/HSTS, trusted proxies for real IP, secret management (no `.env` in image) |
+| `observability-and-logging.mdx` | Structured logging patterns, health-check endpoint, error tracking via `wheels-sentry` package, metrics/APM options (opt-in), request ID middleware |
+
+### New files — Contributing & Project (4 pages)
+
+All under `web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/`:
+
+| Path | Responsibility |
+|------|----------------|
+| `index.mdx` (rewrite) | Landing — how the project is run, core team, code of conduct link, ways to contribute (code / docs / packages / issues), CardGrid |
+| `pull-requests.mdx` | Fork → branch → test → submit workflow; commit message conventions (commitlint scopes); `peter/*` branch prefix; PR review expectations |
+| `coding-standards.mdx` | camelCase naming; CFML component conventions; cross-engine compat rules (summarize `.ai/wheels/cross-engine-compatibility.md`); test-before-push requirement |
+| `writing-docs.mdx` | MDX frontmatter schema; `{test:compile}` / `{test:cli}` / `{test:tutorial}` harness blocks; style guide pointer; `pnpm verify:docs` + `pnpm build` workflow; how to add a page to the sidebar |
+
+### New files — Upgrading (3 pages)
+
+All under `web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/`:
+
+| Path | Responsibility |
+|------|----------------|
+| `index.mdx` (rewrite) | Versioning policy (semver), release cadence, upgrade philosophy, CardGrid to per-version pages |
+| `3x-to-4x.mdx` | **The big one.** Breaking changes (plugins → packages, DI container changes, middleware pipeline, test framework BDD), deprecations, migration checklist, before/after code samples. Sources: `CHANGELOG.md`, `docs/releases/wheels-3.0-vs-4.0.md`, `docs/releases/wheels-4.0-audit.md` |
+| `2x-to-3x.mdx` | Short page. Points readers to v3.0 guides for the 2→3 upgrade proper, then calls out what changed in 4.0 that affects anyone hopping 2→3→4 in one sitting |
+
+### New / rewrite — Glossary (1 page)
+
+| Path | Responsibility |
+|------|----------------|
+| `glossary.mdx` (rewrite) | Alphabetized terms; definitions link to the authoritative guide page; terms harvested from Phase 2a/2b shipped content |
+
+### Modified files
+
+| Path | Change |
+|------|--------|
+| `web/sites/guides/src/sidebars/v4-0-0-snapshot.json` | Populate `items: []` for Deployment & Operations (6), Contributing & Project (4), Upgrading (3). Glossary already single-link. |
+
+### Deleted files
+
+TBD during `.ai/` audit (Task 15). Candidates:
+- `.ai/wheels/contributing-*` (if any — verify)
+- `.ai/wheels/deployment-*` (if any — verify)
+- `.ai/wheels/upgrading/*` (if any — verify)
+
+---
+
+## Phase Layout
+
+| Task | Page / Action | Source authority | Review mode |
+|------|---------------|------------------|-------------|
+| 0 | Create branch, confirm clean base, set up TodoWrite tracking | — | Inline |
+| 1 | `deployment/index.mdx` — Landing | Existing placeholder; design spec deployment goals | Subagent + harness |
+| 2 | `deployment/production-config.mdx` | `config/environment.cfm`, `config/settings.cfm`, framework env-var handling | Subagent + harness |
+| 3 | `deployment/docker-deployment.mdx` | Lucee 7 + Java 21 actuals; `tools/docker/lucee7/Dockerfile` for reference only | Subagent + harness |
+| 4 | `deployment/vm-deployment.mdx` | Lucee admin install docs; nginx/systemd standard patterns | Subagent + harness |
+| 5 | `deployment/security-hardening.mdx` | `vendor/wheels/middleware/SecurityHeaders.cfc`, `vendor/wheels/middleware/Cors.cfc`, CSRF helpers | Subagent + harness |
+| 6 | `deployment/observability-and-logging.mdx` | `vendor/wheels/middleware/RequestId.cfc`, `packages/sentry/` package, Wheels logging conventions | Subagent + harness |
+| 7 | `contributing/index.mdx` — Landing | Legacy `contributing-to-wheels.md` (IA only), current core team, CONTRIBUTING.md if present | Subagent + harness |
+| 8 | `contributing/pull-requests.mdx` | Legacy `submitting-pull-requests.md` (process), `commitlint.config.js`, CLAUDE.md commit conventions | Subagent + harness |
+| 9 | `contributing/coding-standards.mdx` | `.ai/wheels/cross-engine-compatibility.md`, wiki code-style link, test-before-push convention from CLAUDE.md | Subagent + harness |
+| 10 | `contributing/writing-docs.mdx` | `web/sites/guides/scripts/verify-docs/`, style guide, sidebar JSON format | Subagent + harness |
+| 11 | `upgrading/index.mdx` — Landing | Versioning in `box.json` / framework version constant | Subagent + harness |
+| 12 | `upgrading/3x-to-4x.mdx` | `CHANGELOG.md`, `docs/releases/wheels-3.0-vs-4.0.md`, `docs/releases/wheels-4.0-audit.md`, `docs/releases/blog-skeletons/02-upgrading-from-3x.md` | Subagent + harness |
+| 13 | `upgrading/2x-to-3x.mdx` | v3.0 guides + 4.0 changelog relevant entries | Subagent + harness |
+| 14 | `glossary.mdx` — Rewrite | Harvest terms from Phase 2a/2b shipped pages | Subagent + harness |
+| 15 | `.ai/` audit — delete anything superseded by Phase 2c | — | Inline |
+| 16 | Sidebar JSON — wire all 13 new pages into three empty groups | — | Inline |
+| 17 | Full harness + build + Phase 2c report | — | Inline |
+| 18 | Final code review | — | Subagent (pr-review-toolkit:code-reviewer) |
+
+**19 tasks. Expected wall time: 2-3 sessions.** Lighter than 2b-CLI because the page count is smaller and most pages don't need extensive `{test:cli}` coverage (Deployment + Contributing + Upgrading are more narrative than CLI reference).
+
+---
+
+## Shared conventions (carrying forward from Phase 2b)
+
+- **Diátaxis typing:** Deployment pages = `howto`. Contributing pages = `howto`. Upgrading pages = `howto`. Glossary = `reference`. Landing pages = `section`.
+- **Second-person voice.** No marketing copy. Headings at `###` max.
+- **`{test:compile}`** on every non-trivial code block that's valid CFML/MDX. No `{test:cli}` spam — most Phase 2c pages are narrative, not command reference.
+- **Authoritative source cite** at top of each subagent prompt. Drift is prevented by grounding in source, not by review.
+- **Commit message pattern:**
+ ```
+ docs(docs): / —
+ ```
+ Examples:
+ - `docs(docs): deployment/docker-deployment — write production Dockerfile reference`
+ - `docs(docs): upgrading/3x-to-4x — breaking changes and migration checklist`
+- **Sidebar sort order** matches Phase Layout task numbers within each section.
+
+### Verification template (every page)
+
+```bash
+export JAVA_HOME=/opt/homebrew/Cellar/openjdk@21/21.0.8/libexec/openjdk.jdk/Contents/Home
+cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/upbeat-napier-7ccf97/web/sites/guides
+pnpm verify:docs src/content/docs/v4-0-0-snapshot//.mdx
+pnpm build 2>&1 | tail -5
+```
+
+### Cross-page consistency rules
+
+- **No fabricated commands.** If a command doesn't exist in `cli/lucli/Module.cfc` or LuCLI upstream, don't document it. For Deployment especially: `wheels docker *` and `wheels deploy` do NOT exist in v4 at time of writing.
+- **Kamal reference is forward-looking.** `deployment/index.mdx` mentions `wheels deploy` (Kamal port) as in-progress, links to `docs/superpowers/plans/2026-04-20-wheels-deploy-kamal-port.md`. Other deployment pages do NOT assume Kamal.
+- **Upgrading citations.** Every breaking change in `3x-to-4x.mdx` cites a CHANGELOG entry or release doc. Don't paraphrase — quote + link.
+- **Glossary entries** point to the page where the term is fully defined. If a term is used in Phase 2a/2b pages but never defined, flag it in the Phase 2c report — don't invent the definition.
+
+---
+
+## Task details
+
+### Task 0: Create branch + clean base
+
+- [ ] Branch from `develop` at cc2050c39 (or later if develop has advanced): `git checkout -b peter/guides-rewrite-phase-2c develop`
+- [ ] Confirm clean tree, no untracked under `web/sites/guides/src/content/docs/v4-0-0-snapshot/`
+- [ ] Spawn TodoWrite with tasks 1–18 for progress tracking
+
+### Tasks 1–14: Per-page subagent authoring
+
+Each page task follows the same shape. Dispatched as a single subagent call (general-purpose or feature-dev:code-architect):
+
+**Subagent prompt template:**
+```
+You are authoring one page in the Wheels v4 guides rewrite, Phase 2c.
+
+Page: web/sites/guides/src/content/docs/v4-0-0-snapshot/.mdx
+Diátaxis type:
+Scope:
+Source authority:
+
+REQUIREMENTS:
+- Read the style guide at web/sites/guides/docs/writing-style-guide.md
+- Read 2-3 existing Phase 2b pages in the same directory/type to match voice
+- Verify every technical claim against source (cite file + line)
+- Every non-trivial code block gets a {test:compile} or {test:cli} block
+- Close with a "Related" CardGrid to 2-4 sibling pages
+- Run pnpm verify:docs on the page and pnpm build; both must pass
+
+NON-REQUIREMENTS (explicit):
+- Do NOT invent commands. If wheels docker X doesn't exist in cli/lucli/Module.cfc, don't document it.
+- Do NOT copy from legacy docs/src/ without source-verifying — most of it is CommandBox-era.
+
+Commit with: docs(docs): / —
+```
+
+Task 0 sets up the worktree + todos. Tasks 1–14 dispatch subagents per page. Each task is marked complete only after the subagent's commits are on branch AND `pnpm verify:docs` + `pnpm build` pass on the page.
+
+### Task 15: `.ai/` audit
+
+- [ ] List all files under `.ai/wheels/` that mention "contributing", "deployment", "upgrading", or "docs workflow"
+- [ ] For each: determine if Phase 2c pages supersede it
+- [ ] Delete superseded files; update `CLAUDE.md` `.ai/` reference list
+- [ ] Commit: `docs(docs): .ai/ audit — delete files superseded by Phase 2c`
+
+### Task 16: Sidebar wiring
+
+- [ ] Edit `web/sites/guides/src/sidebars/v4-0-0-snapshot.json`
+- [ ] Deployment & Operations: 6 entries, order matches Tasks 1–6
+- [ ] Contributing & Project: 4 entries, order matches Tasks 7–10
+- [ ] Upgrading: 3 entries, order matches Tasks 11–13
+- [ ] Glossary: already single-link, no change
+- [ ] Commit: `docs(docs): sidebar — wire phase 2c pages`
+- [ ] Full `pnpm build` — confirm no broken-link warnings, all 13 pages render
+
+### Task 17: Phase 2c report
+
+Mirror prior reports' structure. File: `docs/superpowers/plans/2026-04-21-guides-rewrite-phase-2c-report.md`.
+
+Sections:
+- Summary (page count, harness blocks, verification status)
+- Drift caught per page (every time source verification surfaced a discrepancy)
+- Source-verification wins
+- Carryover (anything not shipped, tracked as issue)
+- Next (follow-up work)
+
+### Task 18: Final code review
+
+Dispatch `pr-review-toolkit:code-reviewer` across the Phase 2c diff. Resolve high-priority findings before opening PR.
+
+---
+
+## Exit criteria
+
+Phase 2c is done when:
+
+1. 13 new content pages shipped (6 Deployment + 4 Contributing + 3 Upgrading) + glossary rewrite
+2. `pnpm verify:docs` passes on the full tree (no regressions in Phase 0–2b, all new blocks pass)
+3. `pnpm build` produces 351 rendered pages (338 + 13) with no broken-link warnings
+4. Sidebar JSON has no empty `items: []` arrays under v4-0-0-snapshot
+5. Phase 2c report committed
+6. `.ai/` audit committed
+7. Final review resolved
+8. PR opened to `develop` with accurate description
+
+---
+
+## Scope decisions (resolved 2026-04-21)
+
+1. **Dockerfile reference pattern → Lucee 7 canonical, others in a sidebar note.** `wheels new` scaffolds Lucee by default, CI's canary matrix leads with Lucee 7, and writing three parallel Dockerfile patterns triples maintenance for no reader benefit. Adobe/BoxLang get a "same pattern applies, swap the base image" callout.
+2. **Kamal plan link target → link the plan file now, with a grep-able TODO to swap.** The plan file is the only concrete artifact today. Embed `` in `deployment/index.mdx` so the next deployment-touching PR catches the swap.
+3. **`upgrading/2x-to-3x.mdx` → short pointer page (~1 screen).** v3.0 guides are still frozen and live; duplicating the 2→3 upgrade path here creates two sources of truth that will drift. Real value-add is ~20 lines: "if you're hopping 2→4 in one sitting, do 2→3 per the v3 guides first, then read 3x-to-4x."
+4. **Glossary → hand-curated, ~30-50 terms.** Auto-harvest has no reliable "what's a defined term?" rule. Walk Phase 2a/2b pages, pull terms readers will Google or cmd-F (Turbo Frame, DI container, scope, middleware, migrator, package, enum, etc.), one-sentence definitions, link to the page where the concept is fully explained. Automation is a 4.1 polish task.
+5. **`.ai/` audit → scoped to Phase 2c topics.** Matches prior phase precedent. Grep `.ai/wheels/**` for "contributing", "deployment", "upgrading", "docs workflow" — delete what's superseded, leave the rest. A full `.ai/` sweep is on the design spec's list for end-of-Phase-2.
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/coding-standards.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/coding-standards.mdx
new file mode 100644
index 0000000000..08cc1701c2
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/coding-standards.mdx
@@ -0,0 +1,216 @@
+---
+title: Coding standards
+description: Code style, cross-engine rules, and the test-before-push bar for Wheels framework contributors.
+type: howto
+sidebar:
+ order: 3
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+These are the rules for code that ships in `vendor/wheels/`. They exist because Wheels runs on Lucee 5/6/7, Adobe CF 2018-2025, and BoxLang — and each engine will happily let you write code that passes on one and fails on another. Follow the patterns below and you stay on the green side of CI.
+
+**You'll learn:**
+
+- The naming and component conventions the framework uses throughout
+- The cross-engine traps that account for most CI failures
+- The minimum test bar you must clear before pushing
+- What the docs and review process expect of you
+
+
+
+## Naming
+
+- **camelCase everywhere.** Variables, arguments, function names, struct keys. `firstName`, not `first_name` or `FirstName`.
+- **Component files are PascalCase.** `Model.cfc`, `TableDefinition.cfc`. Models singular (`User.cfc`), controllers plural (`Users.cfc`), table names plural lowercase (`users`).
+- **Internal functions get a `$` prefix.** `$integrateComponents`, `$appKey`, `$initModelClass`. The `$` signals "framework internals — do not call from app code."
+- **Tests mirror the subject.** A spec for `BrowserClient.cfc` lives at `tests/specs/.../BrowserClientSpec.cfc`.
+
+## Component conventions
+
+Every framework component extends a framework base and initialises itself in `init()`. `Model.cfc` is the canonical shape:
+
+```cfm title="vendor/wheels/Model.cfc" {test:compile}
+component output="false" displayName="Model" extends="wheels.Global" {
+
+ function init() {
+ $integrateComponents("wheels.model");
+ return this;
+ }
+
+ public any function $initModelClass(required string name, required string path) {
+ variables.wheels = {};
+ variables.wheels.class = {};
+ variables.wheels.class.modelName = arguments.name;
+ return this;
+ }
+}
+```
+
+### The mixin `private` trap — use `public` with a `$` prefix
+
+`$integrateComponents()` only copies **public** methods from mixin CFCs (everything under `vendor/wheels/model/`, `vendor/wheels/controller/`, `vendor/wheels/view/`) into the target object. A helper declared `private` is silently skipped, and the method you expected to call simply does not exist on the integrated component.
+
+```cfm title="vendor/wheels/model/SomeMixin.cfc" {test:compile}
+component {
+
+ // WRONG — integration skips private methods. Caller sees "method not found."
+ private string function myInternalHelper() {
+ return "hi";
+ }
+
+ // RIGHT — public access, $ prefix signals "framework internal"
+ public string function $myInternalHelper() {
+ return "hi";
+ }
+}
+```
+
+BoxLang handles access differently and may make a `private` mixin work by accident — do not trust a BoxLang pass here. Lucee and Adobe will fail.
+
+## Cross-engine rules
+
+These come directly from `.ai/wheels/cross-engine-compatibility.md` and are the top causes of "works on my machine, red on CI" failures.
+
+### 1. `struct.map()` collision on Lucee and Adobe
+
+Both engines resolve `obj.map()` as the built-in struct member function, not your CFC's `map()` method. The DI container exposes `mapInstance()` as the engine-safe alias.
+
+```cfm title="illustrative — do not type" {test:compile}
+// WRONG — triggers struct.map(callback) on Lucee/Adobe
+arguments.container.map("myService").to("path").asSingleton();
+
+// RIGHT — use the alias that avoids the collision
+arguments.container.mapInstance("myService").to("path").asSingleton();
+```
+
+### 2. Adobe CF application scope does not store closures
+
+Adobe's `application` scope is Java-backed and loses function members across requests. Pass a plain struct context instead of mutating `application` directly.
+
+```cfm title="illustrative — do not type" {test:compile}
+// WRONG — works on Lucee, breaks on Adobe
+application.registerMiddleware = function() { return true; };
+
+// RIGHT — plain struct context
+var context = Duplicate(application);
+context.registerMiddleware = function() { return true; };
+```
+
+### 3. Closure `this` captures the declaring scope
+
+A closure binds `this` to where it is defined, not where it is assigned. Test code that dynamically attaches methods to a controller will call back into the spec, not the controller.
+
+```cfm title="illustrative — do not type" {test:compile}
+// WRONG — this.renderText() runs on the test spec
+_controller.myAction = function() {
+ this.renderText("hello");
+};
+
+// RIGHT — capture the reference in a shared struct
+var ctx = {ctrl: _controller};
+_controller.myAction = function() {
+ ctx.ctrl.renderText("hello");
+};
+```
+
+### 4. Bracket-notation calls crash Adobe 2021/2023 inside closures
+
+Split the lookup and the call.
+
+```cfm title="illustrative — do not type" {test:compile}
+// WRONG — crashes Adobe CF 2021/2023 parser inside closures
+var result = obj["dynamicMethod"]();
+
+// RIGHT — two statements
+var fn = obj["dynamicMethod"];
+var result = fn();
+```
+
+### 5. Adobe copies arrays by value inside struct literals
+
+`{arr: myArray}` duplicates the array on Adobe. Closures that append to the copy never touch the original. Reference through a parent struct.
+
+```cfm title="illustrative — do not type" {test:compile}
+// WRONG — Adobe copies myArray into the struct
+var config = {arr: myArray};
+
+// RIGHT — parent struct keeps the reference
+var parent = {arr: myArray};
+var config = {owner: parent};
+```
+
+### 6. `createDynamicProxy` requires a CFC on Lucee 7
+
+Lucee 6 accepted a struct with named function keys. Lucee 7 rejects it with `"Can't cast Complex Object Type Struct to String"`. Use a CFC. `vendor/wheels/wheelstest/DialogConsumer.cfc` is the reference pattern.
+
+### 7. `$appKey()` returns `"$wheels"` — set both scopes
+
+When test setup seeds defaults, set them on both `application.$wheels` and `application.wheels`. CI app reloads can break a single struct reference.
+
+## SQL and CFML string hygiene
+
+- **Never hardcode secrets.** Env vars, `.env` (never committed), or 1Password Connect lookups — never literals in `vendor/wheels/`.
+- **Parameterise queries** at every layer except migrations. In migrations, parameter binding through `execute()` is unreliable; use inline SQL with escaped literals (see the seed-data rule in the top-level project guide).
+- **Escape `#` in CFML string literals.** `#` is the expression delimiter, so HTML entities like `o` in a string become a compile error. Write `#111;`. One unescaped `#` in a spec file crashes the entire test suite, not just that file.
+- **Use `NOW()` in migration SQL** — it is the one timestamp function Wheels normalises across MySQL, PostgreSQL, SQL Server, H2, and SQLite.
+
+## Test before you push
+
+CI runs 20+ minutes across the full matrix. Do not use it as your inner loop. The minimum bar is **Lucee + Adobe** — they catch different bugs.
+
+### Fast loop — LuCLI + SQLite
+
+```bash title="your shell — from the wheels repo root"
+bash tools/test-local.sh # full core suite on Lucee 7 + SQLite
+bash tools/test-local.sh model # model specs only
+bash tools/test-local.sh security # security specs only
+```
+
+The script starts a LuCLI server if one is not running, creates the SQLite DBs, runs the suite, and prints pass/fail counts. No Docker.
+
+### Pre-push minimum — two engines
+
+```bash title="your shell — from wheels/rig" {test:cli cmd="echo 'illustrative'"}
+cd rig
+docker compose up -d lucee6 adobe2025
+
+# Wait ~60s for startup
+curl -sf "http://localhost:60006/wheels/core/tests?db=sqlite&format=json" > /tmp/lucee6.json
+curl -sf "http://localhost:62025/wheels/core/tests?db=sqlite&format=json" > /tmp/adobe2025.json
+```
+
+If either returns HTTP 417 (test failures) or non-zero counts, fix before pushing. See [Running tests locally](/v4-0-0-snapshot/testing/running-tests-locally/) for the full engine-port table and database targets.
+
+
+
+## Docs obligation
+
+Code changes that touch a public surface must update the guides in the same PR:
+
+- **New public API** (framework function, CLI command, middleware, package hook) — add a guide page under the matching section of `web/sites/guides/src/content/docs/v4-0-0-snapshot/`. Pick the Diátaxis type (`tutorial`, `howto`, `concept`, `reference`) and follow `web/sites/guides/STYLE.md`.
+- **Behaviour change to an existing API** — update the existing guide. Do not leave the old wording in place and patch only the release notes.
+- **Internal refactor with no surface change** — no docs update needed.
+
+The verify-docs harness checks every `{test:compile}` and `{test:cli}` block in the guides, so broken example code fails CI.
+
+## Code review expectations
+
+Maintainers flag issues; they do not rewrite your branch for you.
+
+- Expect review comments that point at specific lines with a required change. Push fixes as new commits, not force-pushed rewrites, until the review is approved.
+- A comment like "this needs a test" means add the test before re-requesting review. Do not argue the change is obvious.
+- A comment like "this breaks Adobe" means run the Adobe container locally and prove your fix works before replying.
+- Scope creep gets rejected. One PR, one concern. Open a follow-up for the adjacent cleanup you spotted.
+
+## Related guides
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/index.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/index.mdx
index dd00ec2c52..1b46809388 100644
--- a/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/index.mdx
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/index.mdx
@@ -1,9 +1,78 @@
---
title: Contributing & Project
-description: How to contribute code, docs, and packages to Wheels.
+description: How Wheels is maintained, who the core team is, and the four ways you can help move the framework forward.
type: section
sidebar:
order: 8
---
-Placeholder — content lands in Phase 2.
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+Wheels is an open-source project with a small core team and a steady stream of community contributions. This section is for anyone who wants to move the framework itself forward — fixing a bug, adding a feature, improving a guide, shipping a package, or helping triage the issue queue. If you're building an app *with* Wheels, the rest of the site is the right place; this section is about working *on* Wheels.
+
+**You'll learn:**
+
+- How the project is governed and who has merge rights
+- The four contribution paths and which one fits what you want to do
+- Where to find the full PR process, coding standards, and docs-writing conventions
+
+## How the project is run
+
+Wheels has a **core team** with merge rights on [`wheels-dev/wheels`](https://github.com/wheels-dev/wheels), and everyone else contributes through pull requests. Those PRs go through the same review process — one maintainer approval before merge — regardless of who opened them. Core team members open PRs too; nothing lands on `develop` without review.
+
+The current core team, verified against recent git history on `develop`:
+
+- **Peter Amiri** ([@bpamiri](https://github.com/bpamiri)) — project lead, primary maintainer
+- **Zain Ul Abideen** ([@zainforbjs](https://github.com/zainforbjs)) — maintainer
+
+Core team responsibilities: reviewing and merging PRs, triaging issues, cutting releases, and steering the roadmap. The team is intentionally small so that review throughput stays high and architectural direction stays coherent. Community contributors expand both the reach of the framework and the pool of expertise — a recent `develop` history shows PRs from dozens of contributors across models, middleware, CLI, and docs.
+
+If you're unsure whether an idea will be accepted, open an issue first and tag [@bpamiri](https://github.com/bpamiri) or [@zainforbjs](https://github.com/zainforbjs). It's cheaper than writing a PR that gets closed.
+
+## Code of conduct
+
+Participation in Wheels spaces — GitHub, discussions, issues, PRs — is governed by the [Contributor Covenant Code of Conduct](https://github.com/wheels-dev/wheels/blob/develop/CODE_OF_CONDUCT.md). Report incidents to `conduct@wheels.dev`. Reports are reviewed promptly and confidentially.
+
+## Four ways to contribute
+
+Pick the one that matches what you want to do. Every path is welcome and every path has a review process.
+
+### Code
+
+Bug fixes, new features, performance work, refactors. If you're changing files in `vendor/wheels/` or adding framework behaviour, this is your path. Start from an issue — either one that already exists or one you open to describe the problem before you write code. Breaking changes and anything touching core functionality should be discussed in an issue *before* a PR lands. See [Pull Requests](/v4-0-0-snapshot/contributing/pull-requests/) for the full flow and [Coding Standards](/v4-0-0-snapshot/contributing/coding-standards/) for style and structure rules.
+
+### Documentation
+
+Fixing a typo, clarifying a guide, adding a missing example, filling a gap. Docs PRs are exempt from the test-writing requirement but still go through review. The [Writing Documentation](/v4-0-0-snapshot/contributing/writing-docs/) guide covers the Diátaxis split (tutorial / how-to / concept / reference), the verify-docs harness that compiles every tagged code block, and the style rules that keep the site consistent.
+
+### Packages
+
+First-party packages live in `packages/` and are activated by copying into `vendor/`. Good candidates: opt-in integrations (error trackers, UI toolkits, front-end frameworks) that don't belong in the framework core. A new package needs a `package.json` manifest, tests in `packages//tests/`, and a docs page under Digging Deeper. See [Packages](/v4-0-0-snapshot/digging-deeper/packages/) for the manifest shape and activation model.
+
+### Issue triage
+
+Reproducing bug reports, labelling issues, closing duplicates, pointing people at existing answers. This is high-leverage work — one person triaging an hour a week keeps the queue healthy for everyone. No special permissions needed; start by commenting on open issues. The core team will add the `triage` label to contributors who are consistent.
+
+
+
+## Where to go next
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/pull-requests.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/pull-requests.mdx
new file mode 100644
index 0000000000..ddc50a8f62
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/pull-requests.mdx
@@ -0,0 +1,162 @@
+---
+title: Submitting pull requests
+description: Fork, branch, test locally, commit with the right scope, open the PR, iterate through review to merge.
+type: howto
+sidebar:
+ order: 2
+---
+
+import { Aside, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components';
+
+The Wheels repo accepts contributions through standard GitHub pull requests against the `develop` branch. The path that gets your change merged fastest is the same one the core team uses: fork, branch, run `bash tools/test-local.sh` before every push, commit with a type and scope that pass `commitlint`, open a PR against `develop` with a filled-out test plan, and respond to review comments as they come in.
+
+**You'll learn:**
+
+- How to fork, clone, and wire up an `upstream` remote so you can keep your branch current
+- How the repo expects branches, commits, and PR bodies to be shaped
+- Exactly which commit types and scopes `commitlint.config.js` accepts — and what happens when you miss
+- What to expect during review, iteration, and merge
+
+
+
+## Fork, clone, and set upstream
+
+
+
+1. Fork `wheels-dev/wheels` on GitHub into your account or org.
+
+2. Clone your fork and set the canonical repo as `upstream`:
+
+ ```bash title="your shell"
+ git clone git@github.com:YOUR_USERNAME/wheels.git
+ cd wheels
+ git remote add upstream git@github.com:wheels-dev/wheels.git
+ git fetch upstream
+ ```
+
+3. Track `develop` from upstream so rebases and pulls are unambiguous:
+
+ ```bash title="your shell"
+ git checkout -B develop upstream/develop
+ ```
+
+
+
+The default target branch for PRs is `develop`, not `main`. PRs opened against `main` get redirected during review.
+
+## Create a branch
+
+Branch names are a personal namespace — the repo doesn't enforce a prefix. The core team uses a personal prefix (e.g. `peter/*`) so their branches are easy to spot in the remote list; contributors commonly use `fix/*`, `feat/*`, or their own handle. Pick something short and descriptive.
+
+```bash title="your shell"
+git checkout -B feat/route-model-binding upstream/develop
+```
+
+One logical change per branch. Unrelated fixes belong in separate PRs — they review faster and revert cleanly if something regresses.
+
+## Run the tests locally before every push
+
+This is non-negotiable. CI runs the full cross-engine matrix, but you verify Lucee 7 + SQLite locally before pushing — it catches 90% of issues in under a minute, and it keeps CI queue time healthy for everyone.
+
+```bash title="your shell — in the repo root"
+bash tools/test-local.sh # full core suite
+bash tools/test-local.sh model # just model specs
+bash tools/test-local.sh security # just security specs
+```
+
+The script creates the SQLite test databases, starts a disposable LuCLI server on port 8080 if one isn't already up, runs the suite, prints a coloured pass/fail summary, and cleans up on exit. See [Running Tests Locally](/v4-0-0-snapshot/testing/running-tests-locally/) for the full command surface, filter aliases, and the Docker cross-engine matrix for pre-merge verification.
+
+
+
+## Commit with a valid type and scope
+
+The repo enforces [Conventional Commits](https://www.conventionalcommits.org/) via `commitlint`. The config lives at `commitlint.config.js` in the repo root. CI rejects PRs whose commits don't parse.
+
+**Shape:** `type(scope): lowercase subject`
+
+**Valid types** (from `@commitlint/config-conventional`, referenced in `commitlint.config.js` line 13):
+
+`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
+
+**Valid scopes** (`scope-enum` allowlist, `commitlint.config.js` lines 17–44):
+
+`model`, `controller`, `view`, `router`, `middleware`, `migration`, `cli`, `test`, `config`, `di`, `job`, `mailer`, `plugin`, `sse`, `seed`, `docs`, `web`, `web/ui`, `web/landing`, `web/blog`, `web/guides`, `web/api`, `web/starlight`
+
+The scope is optional — you may omit the `(scope)` parenthetical entirely. When present, it must be one of the values above. There is no `security` scope; use the layer the fix touches (`model` for a SQL-injection fix, `view` for XSS, `config` for consoleeval hardening, `cli` for MCP server fixes).
+
+**Subject casing:** only ALL-CAPS is rejected (`subject-case` rule at line 56). Proper nouns like "Giscus", "CockroachDB" are fine. Header length caps at 100 characters.
+
+```text title="examples that pass"
+feat(router): add route model binding
+fix(model): correct association eager loading
+docs(web/guides): rewrite testing section
+test: add coverage for rate limiter
+```
+
+```text title="examples that get rejected"
+Feat: add route model binding # type must be lowercase
+fix(security): patch XSS in forms # security is not an allowed scope
+UPDATE MODEL # subject is ALL-CAPS
+```
+
+## Open the pull request
+
+Push your branch and open the PR against `upstream/develop`.
+
+```bash title="your shell"
+git push -u origin feat/route-model-binding
+gh pr create --base develop --web
+```
+
+The repo ships a PR template at `.github/PULL_REQUEST_TEMPLATE.md` with these required sections:
+
+- **Summary** — one short paragraph describing what the PR does.
+- **Related Issue** — `Closes #123` if the PR addresses a tracked issue; otherwise state that it's standalone.
+- **Type of Change** — tick the matching box (bug fix, new feature, enhancement, documentation, refactoring).
+- **Feature Completeness Checklist** — for new features and enhancements, confirm tests, framework docs, `.ai/wheels/` reference docs, `CLAUDE.md` updates if conventions changed, and a `CHANGELOG.md` entry under `[Unreleased]`.
+- **Test Plan** — the exact steps a reviewer can run to verify your change. Commands, expected output, URLs to hit. Skipping this is the single biggest cause of review delays.
+- **Screenshots / Output** — for UI-touching or output-changing PRs.
+
+Fill every section. Empty headings telegraph "I didn't read the template" and slow your review down.
+
+## Review, iterate, merge
+
+Expect an initial look from a maintainer within a few business days. Smaller, well-scoped PRs with complete test plans move faster than large, thin ones.
+
+During review you'll see:
+
+- **Inline comments on the diff** — respond per-comment; push follow-up commits on the same branch rather than force-pushing. The PR updates automatically.
+- **Requests for missing pieces** — test coverage, a `CHANGELOG.md` entry, a `.ai/wheels/` doc. These are listed in the template for a reason; add them rather than arguing they don't apply.
+- **Cross-engine fixes** — if CI flags Adobe CF or BoxLang failures a local Lucee run didn't catch, consult the cross-engine gotcha list in [Running Tests Locally](/v4-0-0-snapshot/testing/running-tests-locally/#cross-engine-gotchas). Verify your fix against the local Docker container for that engine before pushing.
+
+Keep your branch up to date if review takes long enough that `develop` drifts:
+
+```bash title="your shell"
+git fetch upstream
+git rebase upstream/develop
+git push --force-with-lease
+```
+
+Use `--force-with-lease`, not `--force`. It refuses the push if someone else pushed to your branch meanwhile — protecting co-contributor commits.
+
+When review is satisfied, a maintainer merges. The repo's current convention is squash-and-merge for feature and fix branches, preserving a single conventional-commit message on `develop`. You'll see the merged commit land on `develop`; your branch on the fork is safe to delete (`gh pr checkout` offers to delete after merge, or `git branch -D` locally + delete the remote on GitHub).
+
+## After merge
+
+A handful of optional follow-ups keep your local environment clean:
+
+- **Delete the merged branch** both locally and on your fork.
+- **Pull `develop`** into your local clone so your next branch starts from the latest tree.
+- **Check the `[Unreleased]` section of `CHANGELOG.md`** — if your PR added an entry, confirm it rendered correctly.
+- **Watch the release notes** — merged PRs surface in the next version's notes under the matching section (Features, Fixes, Documentation).
+
+## Related guides
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/writing-docs.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/writing-docs.mdx
new file mode 100644
index 0000000000..5ba7b82981
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/contributing/writing-docs.mdx
@@ -0,0 +1,220 @@
+---
+title: Writing documentation
+description: How to add or edit a page in the Wheels v4 guides — file layout, frontmatter, verify-docs harness, sidebar wiring, and the review loop.
+type: howto
+sidebar:
+ order: 4
+---
+
+import { Aside, CardGrid, LinkCard, FileTree, Steps } from '@astrojs/starlight/components';
+
+The v4 guides live in `web/sites/guides/` as a Starlight site. Every page is an `.mdx` file under a versioned content tree, every non-illustrative code block is validated by the `verify-docs` harness, and every entry in the sidebar is wired up explicitly in a JSON file. This guide walks you end-to-end: pick a Diátaxis type, write the frontmatter, test your code blocks, add the sidebar entry, and open a PR.
+
+**You'll learn:**
+
+- Where pages live and how to name a new file
+- The three frontmatter shapes — `howto`, `reference`, `section` (landing)
+- How to tag code blocks for the `verify-docs` harness (`{test:compile}`, `{test:cli}`, `{test:tutorial}`)
+- How to wire your page into `src/sidebars/v4-0-0-snapshot.json`
+- The local workflow: write → `pnpm verify:docs` → `pnpm build` → PR
+
+
+
+## Site layout
+
+Every published page lives under a versioned content folder. The v4 snapshot is frozen at `v4-0-0-snapshot/`; a future `v4-1-0/` will sit beside it.
+
+
+- web/sites/guides/
+ - src/
+ - content/docs/
+ - v4-0-0-snapshot/
+ - start-here/
+ - core-concepts/
+ - basics/
+ - digging-deeper/
+ - testing/
+ - command-line-tools/
+ - contributing/
+ - index.mdx
+ - writing-docs.mdx
+ - upgrading/
+ - glossary/
+ - sidebars/
+ - v4-0-0-snapshot.json
+ - scripts/verify-docs/
+ - VALIDATION.md
+ - verify-docs.mjs
+ - drivers/
+ - cli.mjs
+ - compile.mjs
+ - tutorial.mjs
+ - STYLE.md
+ - package.json
+
+
+The path on disk is the URL. `contributing/writing-docs.mdx` renders at `/v4-0-0-snapshot/contributing/writing-docs/`. Directory landing pages are named `index.mdx`. Use kebab-case filenames; the slug segment comes directly from the filename.
+
+## Pick a Diátaxis type
+
+Every page frontmatter carries `type:`, and the quadrant determines the shape of the page. One type per page — no mixing. From the [style guide](https://github.com/wheels-dev/wheels/blob/develop/web/sites/guides/STYLE.md):
+
+- **`tutorial`** — learning-oriented, hand-held. The reader builds something alongside you. Ends with Checkpoint and Troubleshooting. Use this only inside `start-here/tutorial/`.
+- **`howto`** — task-oriented. The reader knows what they want; you show them how. Ends with a Related ``.
+- **`concept`** — understanding-oriented. "Why Wheels does X." No commands, no steps. Ends with a See-also block.
+- **`reference`** — information-oriented. Dry, tables and lists, no narrative.
+- **`section`** — the landing page for a top-level sidebar section (e.g., `contributing/index.mdx`). Not a Diátaxis quadrant strictly, but we use it for hub pages.
+
+If a page feels like it needs two types, it's two pages. Split it.
+
+## Frontmatter shapes
+
+Copy one of the three canonical shapes below. All three are YAML between `---` fences at the very top of the file — no blank line above them, two-space indentation under `sidebar:`.
+
+A **howto** page:
+
+```yaml title="frontmatter — howto page"
+---
+title: Running tests locally
+description: The wheels test CLI, tools/test-local.sh, filtering, and the Docker cross-engine matrix.
+type: howto
+sidebar:
+ order: 9
+---
+```
+
+A **reference** page:
+
+```yaml title="frontmatter — reference page"
+---
+title: Form helpers
+description: Every form helper, its signature, and an example — the full table.
+type: reference
+sidebar:
+ order: 4
+---
+```
+
+A **section** landing page (`index.mdx` for a top-level sidebar category):
+
+```yaml title="frontmatter — section landing"
+---
+title: Contributing & Project
+description: How Wheels is maintained and the four ways you can help move the framework forward.
+type: section
+sidebar:
+ order: 8
+---
+```
+
+`sidebar.order` controls ordering when the sidebar is auto-generated; it's cosmetic when the sidebar is explicitly wired (which ours is), but it still controls fallback ordering in search and metadata. Keep it consistent with the order you wire into the JSON.
+
+## Validate code blocks with the harness
+
+Every non-illustrative fenced code block carries a `{test:*}` meta flag. The harness — `pnpm verify:docs` — parses those flags, runs each block through the right driver, and fails the build on mismatches. The canonical spec lives in `web/sites/guides/scripts/verify-docs/VALIDATION.md`; three drivers ship today.
+
+### `{test:compile}` for CFML syntax
+
+Pipes the block body into `wheels cfml` and passes if it exits 0. Use this on any CFML snippet you want to guarantee actually parses.
+
+```cfm {test:compile}
+component extends="Model" {
+ function config() {
+ validatesPresenceOf("title");
+ }
+}
+```
+
+### `{test:cli ...}` for CLI invocations
+
+Runs `cmd` in a fresh fixture app and asserts against stdout, stderr, or exit code. Useful for `wheels` commands whose output you want to pin.
+
+```bash {test:cli cmd="wheels --version" asserts-stdout="Wheels"}
+wheels --version
+```
+
+Supported attrs: `cmd="..."`, `asserts-stdout`, `asserts-stderr`, `asserts-output` (either stream), `asserts-exit=N`, `step=N`. No shell features — no pipes, redirects, `&&`, or quoted args with spaces. The harness spawns the program directly.
+
+### `{test:tutorial ...}` for the tutorial fixture
+
+The tutorial-specific driver. Each block writes a file into a shared `blog-tutorial` fixture app at a given step, optionally asserting an HTTP response or row count afterward. Mostly used inside `start-here/tutorial/` — you won't reach for it in a howto unless you're extending the tutorial itself.
+
+Meta-syntax for documenting the harness (as on this page) is tricky because the harness also parses nested fences. Keep illustrative meta examples inside prose, not inside a fenced block that claims to be tested.
+
+### Illustrative blocks
+
+Blocks that cannot or should not compile get a title instead of a test flag — the harness ignores any block with no `{test:*}`.
+
+```cfm title="illustrative — do not type"
+someAPI.callThat.doesntExistYet();
+```
+
+
+
+See `web/sites/guides/scripts/verify-docs/VALIDATION.md` for the full schema, edge cases, and the fallback-mode caveat that applies when LuCLI's `wheels cfml` doesn't exit non-zero on CFML errors.
+
+## Wire the sidebar
+
+The sidebar is an explicit JSON tree — no auto-discovery. Edit `web/sites/guides/src/sidebars/v4-0-0-snapshot.json` and add your page under the matching section's `items` array.
+
+```json title="web/sites/guides/src/sidebars/v4-0-0-snapshot.json"
+{
+ "label": "Contributing & Project",
+ "link": "/v4-0-0-snapshot/contributing/",
+ "items": [
+ { "label": "Writing documentation", "link": "/v4-0-0-snapshot/contributing/writing-docs/" }
+ ]
+}
+```
+
+The `label` is what appears in the sidebar; `link` is the URL. Nested groups use the same shape — a `label` plus an `items` array, optionally with a `link` for a landing page. Look at the `Start Here > Tutorial` entry for a nested example.
+
+## The local workflow
+
+
+
+1. **Write the page.** Pick the type, drop in the frontmatter, structure by the style guide's rules (1-sentence summary + "You'll learn" list, `
+
+## Close with a Related CardGrid
+
+Every howto ends with a Related ``. Three or four ``s — adjacent guides the reader will plausibly want next, with a terse description each. Match the tone of the rest of the site: no marketing copy, no exclamation marks.
+
+## Common gotchas
+
+- **CFML `##` in fenced blocks.** As above — CFML's `#` is the expression delimiter, so literal `#` characters in strings must be doubled. This is the single most common reason a `{test:compile}` block fails unexpectedly.
+- **MDX curly braces.** MDX parses `{ ... }` as a JSX expression. In prose, escape as `\{`. For comments, use `{/* like this */}` — HTML comments render literally in MDX.
+- **Frontmatter YAML indentation.** Two spaces, no tabs. `sidebar:` needs `order:` on the following line indented by exactly two spaces. A stray tab silently breaks the frontmatter and every page's `` renders as its filename.
+- **Don't mix Diátaxis types in one page.** A howto that drifts into conceptual explanation should either split, or move the concept to a sibling `concept` page and link to it. Every page carries exactly one `type:`.
+- **Don't reorder the sidebar for your page.** Add at the correct position up-front — inserting later is a review thrash.
+- **Close CFML code blocks with `cfm`, not `cfml`.** Starlight's syntax highlighter and the harness both key on `cfm`. `bash`, `json`, `yaml`, `mdx` work as you'd expect.
+
+## Related guides
+
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/docker-deployment.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/docker-deployment.mdx
new file mode 100644
index 0000000000..8f796f460b
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/docker-deployment.mdx
@@ -0,0 +1,316 @@
+---
+title: Docker deployment
+description: Package a Wheels app as a container image and run it in production with Lucee 7 and Java 21.
+type: howto
+sidebar:
+ order: 3
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+This page shows you how to containerize a Wheels app for production. You'll write a multi-stage `Dockerfile` that produces a small runtime image, wire up a `docker-compose.yml` for the app and a Redis sidecar, pass configuration through environment variables, add a health check, and publish a multi-arch image with `docker buildx`.
+
+**You'll learn:**
+
+- Why Lucee 7 + Java 21 is the recommended base
+- How to structure a multi-stage Dockerfile so the runtime layer stays small
+- What to exclude with `.dockerignore` and how to pass secrets through environment variables
+- How to add a health check endpoint to a Wheels app
+- How to publish `linux/amd64` + `linux/arm64` images with `buildx`
+
+
+
+
+
+## Why Lucee 7 and Java 21
+
+The framework's CI matrix pins Lucee 7 on Java 21 as the primary production target. Lucee 7 boots faster than Lucee 5 or 6, the JVM footprint on Java 21 is smaller, and the combination matches what the framework's own test suite runs against — if it passes CI, it runs on this stack. Adobe ColdFusion 2023 and BoxLang are also supported; the Dockerfile pattern is the same, only the base image and `CMD` change. See the callout at the bottom of this page for the swaps.
+
+## The Dockerfile
+
+Write a `Dockerfile` at the root of your app. Two stages: a **builder** that installs any build-time dependencies (node modules for a Tailwind/Vite build, for example), and a **runtime** that copies only the artifacts it needs onto a slim Lucee image.
+
+```dockerfile title="Dockerfile"
+# syntax=docker/dockerfile:1.7
+
+# --- Stage 1: builder ---------------------------------------------------------
+# Compile front-end assets and prune anything the runtime doesn't need.
+FROM node:20-bookworm-slim AS builder
+
+WORKDIR /build
+
+COPY package.json package-lock.json* ./
+RUN npm ci --no-audit --no-fund
+
+COPY . .
+RUN npm run build # produces public/assets/ (Vite, Tailwind, etc.)
+
+# --- Stage 2: runtime ---------------------------------------------------------
+# Lucee 7 on Java 21. The lucee/lucee image ships a ready webroot at /var/www.
+FROM lucee/lucee:7-tomcat10-jre21
+
+LABEL org.opencontainers.image.source="https://github.com/your-org/your-app"
+
+# Wheels reads these at boot. Override per-environment at `docker run` time.
+ENV WHEELS_ENV=production \
+ WHEELS_DATASOURCE=app \
+ RELOAD_PASSWORD=change-me \
+ CATALINA_OPTS="-Xms256m -Xmx1g -XX:MaxMetaspaceSize=256m"
+
+WORKDIR /var/www
+
+# Copy the app. Vendor code (framework + activated packages) ships inside the
+# image so production boot needs no network access.
+COPY --from=builder /build/app ./app
+COPY --from=builder /build/config ./config
+COPY --from=builder /build/public ./public
+COPY --from=builder /build/vendor ./vendor
+COPY --from=builder /build/Application.cfc ./Application.cfc
+COPY --from=builder /build/index.cfm ./index.cfm
+
+# Tomcat listens on 8080 in the lucee/lucee image.
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
+ CMD curl -fsS http://127.0.0.1:8080/health || exit 1
+```
+
+Why multi-stage:
+
+1. **Smaller runtime image.** `node_modules` is several hundred megabytes. Keeping it in the builder stage means it never ships.
+2. **Reproducible front-end builds.** The builder stage pins Node 20, so the compiled CSS and JS are identical regardless of the host that ran `docker build`.
+3. **One image, any environment.** The runtime reads `WHEELS_ENV` at boot. The same artifact boots against development, staging, or production by swapping env vars — no rebuild.
+
+
+
+## The .dockerignore
+
+Keep the build context lean. Everything in `.dockerignore` is invisible to `COPY . .`, which means a cleaner image and a faster build.
+
+```text title=".dockerignore"
+.git
+.gitignore
+.github
+.env
+.env.*
+node_modules
+tests
+vendor/wheels/tests
+public/assets/*.map
+logs
+tmp
+*.log
+Dockerfile
+docker-compose.yml
+README.md
+docs
+.claude
+.ai
+.vscode
+.idea
+```
+
+Three rules worth calling out:
+
+- `**.env*` — never bake secrets into an image. Pass them at runtime (next section).
+- `vendor/wheels/tests` — the framework test suite is 80+ MB of fixtures you don't need in production.
+- `public/assets/*.map` — JavaScript and CSS sourcemaps leak your source to anyone who opens the browser devtools. Drop them on the builder floor.
+
+## docker-compose.yml
+
+Compose is enough for a single-host deployment (a VM, a bare-metal box, a Fly.io or Render machine). Here's an app + Redis sidecar — Redis is optional, but if you use Wheels session storage or the `RateLimiter` middleware with `storage="database"` you may want an in-memory cache in front.
+
+```yaml title="docker-compose.yml"
+services:
+ app:
+ build: .
+ image: ghcr.io/your-org/your-app:latest
+ restart: unless-stopped
+ ports:
+ - "80:8080"
+ environment:
+ WHEELS_ENV: production
+ WHEELS_DATASOURCE: app
+ RELOAD_PASSWORD: ${RELOAD_PASSWORD:?required}
+ DB_HOST: db.internal.example.com
+ DB_NAME: myapp_prod
+ DB_USER: myapp
+ DB_PASSWORD: ${DB_PASSWORD:?required}
+ REDIS_HOST: redis
+ REDIS_PORT: "6379"
+ depends_on:
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 60s
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redis-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+
+volumes:
+ redis-data:
+```
+
+Bring it up:
+
+```bash title="your shell"
+export RELOAD_PASSWORD="$(openssl rand -hex 32)"
+export DB_PASSWORD="$(pbpaste)"
+docker compose up -d
+docker compose logs -f app
+```
+
+The `:?required` suffix on an env var makes Compose refuse to start if the variable is missing — cheap guardrail against pushing an image with a blank reload password.
+
+## Environment variables
+
+Wheels reads environment variables at boot through `config/settings.cfm` and `config/environment.cfm`. The scaffolded app ships with a small set; your app will add more.
+
+| Variable | Where it's read | Typical value |
+|----------|----------------|---------------|
+| `WHEELS_ENV` | `config/environment.cfm` — selects the environment file | `production` |
+| `WHEELS_DATASOURCE` | `config/settings.cfm` — the datasource name your models use | `app` |
+| `RELOAD_PASSWORD` | `config/settings.cfm` — guards `?reload=true` | a 32-byte random string |
+| `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` | `Application.cfc` or your custom datasource config | your DB credentials |
+
+Resolve these inside the app with `Server.System.getEnv("DB_HOST")`:
+
+```cfm {test:compile} title="config/environment.cfm"
+component {
+ function config() {
+ set(dataSourceName=Server.System.getEnv("WHEELS_DATASOURCE") ?: "app");
+ set(reloadPassword=Server.System.getEnv("RELOAD_PASSWORD") ?: "changeme");
+ }
+}
+```
+
+Keep secrets **out** of the image. `docker compose` reads them from your shell's environment, from a `.env` file that's `.gitignore`'d, or from whatever secret store your host provides (Fly's `fly secrets set`, Render's dashboard, AWS Parameter Store).
+
+## Health check
+
+The Dockerfile and Compose file above both call `GET /health`. That route doesn't exist in Wheels out of the box — add it yourself. A health action is any controller action that responds quickly without touching external services.
+
+```cfm {test:compile} title="app/controllers/Health.cfc"
+component extends="Controller" {
+ function index() {
+ // Cheap check — no DB call. Kubernetes/Docker probes run this every
+ // few seconds, so keep it under a millisecond.
+ renderText(text="ok", status=200);
+ }
+
+ function ready() {
+ // Readiness check — does touch the DB. Use this for load balancer
+ // drain/enable logic, not for the fast Docker HEALTHCHECK.
+ try {
+ model("User").count();
+ renderText(text="ready", status=200);
+ } catch (any e) {
+ renderText(text="not ready: #e.message#", status=503);
+ }
+ }
+}
+```
+
+Wire it into the router before the `wildcard()`:
+
+```cfm {test:compile} title="config/routes.cfm"
+component {
+ function config() {
+ mapper()
+ .get(name="health", pattern="/health", to="health##index")
+ .get(name="ready", pattern="/ready", to="health##ready")
+ .wildcard()
+ .end();
+ }
+}
+```
+
+Two endpoints, two purposes. `/health` is for the container runtime — fast and dumb, answers "is the process alive." `/ready` is for your load balancer — slower, answers "can this instance serve traffic." Point the `HEALTHCHECK` at `/health` and the Relianoid/ALB health probe at `/ready`.
+
+## Image size
+
+A clean Wheels runtime image lands around 500–700 MB — most of that is Tomcat, Lucee, and the JRE. You can push it lower:
+
+- **JRE, not JDK.** The `lucee/lucee:7-tomcat10-jre21` tag is ~150 MB smaller than `jdk21`.
+- **Slim base.** If you really need sub-300 MB, build on a slim JRE + install Lucee manually. The prebuilt image is worth the extra MB on every host except the most constrained edge nodes.
+- **Drop test fixtures.** `.dockerignore` should always exclude `vendor/wheels/tests` and your own `tests/` directory.
+- **Layer cache.** `COPY package.json package-lock.json ./` **before** `COPY . .` so a docs-only change doesn't re-run `npm ci`.
+- **One `RUN` per concern.** Every `RUN` creates a layer. Combine `apt-get update && apt-get install && rm -rf /var/lib/apt/lists/*` into a single line.
+
+Alpine is tempting — the base image is tiny — but Lucee on Alpine has historically had glibc/musl issues with some JDBC drivers. The Debian-slim-based `lucee/lucee:7-tomcat10-jre21` is the path of least resistance for production.
+
+## Multi-arch images with buildx
+
+If some of your hosts are ARM (a Mac laptop for dev, Graviton EC2 in production) and others are x86, publish a single tag that serves both. `docker buildx` handles it.
+
+One-time setup:
+
+```bash title="your shell"
+docker buildx create --name multiarch --use
+docker buildx inspect --bootstrap
+```
+
+Then build and push:
+
+```bash title="your shell"
+docker buildx build \
+ --platform linux/amd64,linux/arm64 \
+ --tag ghcr.io/your-org/your-app:latest \
+ --tag ghcr.io/your-org/your-app:v4.0.0 \
+ --push \
+ .
+```
+
+The registry now has a manifest list — `docker pull` on an arm64 Mac fetches the arm64 variant, on an amd64 box it fetches amd64. No other changes needed on the consumer side.
+
+
+
+## Adobe ColdFusion and BoxLang
+
+The same Dockerfile pattern works on other CFML engines — only the base image and startup command differ.
+
+- **Adobe ColdFusion 2023:** `FROM adobecoldfusion/coldfusion:2023-ubi8-latest`. The webroot is `/app`. Set your datasource in the CF admin or via a `neo-datasource.xml` copied in at build time.
+- **BoxLang:** `FROM ortussolutions/boxlang:latest`. Boot with `CMD ["boxlang", "miniserver"]` and expose `8080`.
+
+Everything else — `.dockerignore`, Compose wiring, env vars, health check action, buildx — is identical. Wheels behaves the same across engines; Docker behaves the same regardless of what's inside the container.
+
+## Related guides
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/index.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/index.mdx
index 755a0aec19..f1b5bb4cd2 100644
--- a/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/index.mdx
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/index.mdx
@@ -1,9 +1,110 @@
---
title: Deployment & Operations
-description: Ship a Wheels app to production.
+description: How Wheels apps reach production — the two supported models, what the framework provides, and what you provide.
type: section
sidebar:
order: 6
---
-Placeholder — content lands in Phase 2.
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+A Wheels app in production is a Java webapp running on a CFML engine. That one fact determines everything that follows — your deployment target is anything that can host a JVM process and terminate HTTP in front of it. This page is the map for the Deployment & Operations section. It covers the two models Wheels supports today, the line between what the framework gives you and what you own, and where each detail page lives.
+
+**You'll learn:**
+
+- The two deployment models Wheels supports today and how to choose between them
+- Exactly what the framework ships versus what production infrastructure you provide
+- The current state of the `wheels deploy` CLI and how to deploy without it
+
+## The two models
+
+Every Wheels deployment the authors have seen in the wild fits one of two shapes. Pick the one that matches your team's operational maturity, not the one that looks modern.
+
+| | Containerized (Docker) | Traditional (VM / bare metal) |
+|---|---|---|
+| **Artifact** | OCI image containing Lucee + your app | Directory of app code plus an installed engine |
+| **Runtime** | Container runtime (Docker, Podman, Kubernetes) | systemd service running CommandBox or a servlet container |
+| **Updates** | Build image, push to registry, roll out new image | Pull code, restart service |
+| **Dev/prod parity** | High — same image in both environments | Lower — dev and prod diverge on engine version, JVM flags, OS libs |
+| **Ops burden** | Registry, orchestrator or Compose, image hygiene | Config management (Ansible/Puppet/Chef), package pinning |
+
+### Pick containerized when
+
+- Your team already runs containers for other services. The existing registry, orchestration, and observability stack applies unchanged.
+- You want one artifact you can run identically on a laptop, in CI, and in production.
+- You're comfortable operating a registry and either an orchestrator or a Compose-based host.
+
+### Pick traditional when
+
+- You have existing VM or bare-metal infrastructure with mature config management, and adding a container layer adds cost without reducing risk.
+- Compliance, audit, or network topology constraints make containers harder than they're worth.
+- Your team is small and one long-lived host per environment is simpler than an orchestrator.
+
+Both models are first-class. The detail pages go deep on each.
+
+## What Wheels provides vs. what you provide
+
+The framework is deliberately narrow about production. Wheels gives you a CFML app that boots on any supported engine, configures itself from environment variables, and exposes health and observability hooks. Everything outside the JVM process is yours.
+
+**Wheels provides:**
+
+- A CFML application that runs on Lucee 5/6/7, Adobe ColdFusion 2018/2021/2023/2025, or BoxLang. The 4.0 reference engine for core tests is Lucee 7 on Java 21.
+- Environment-aware configuration (`config/environment.cfm`, `config/environments/*.cfm`) that selects behavior by `WHEELS_ENV`.
+- Secrets loaded from `.env` (dev) or process environment (prod) via `config/settings.cfm`.
+- A migrator (`wheels dbmigrate`) and seeder (`wheels seed`) that run against your production database on deploy.
+- Built-in middleware for request IDs, security headers, CORS, and rate limiting — wired in `config/settings.cfm`.
+- A reload endpoint (`?reload=true&password=...`) and health-check surface your orchestrator or load balancer can poll.
+
+**You provide:**
+
+- The container or VM. Wheels does not ship a canonical base image or bake-a-VM recipe.
+- The CFML engine install and its JVM. A typical production Wheels container runs Lucee 7 on Java 21; a typical VM install uses the same engine under CommandBox or a standalone servlet container.
+- A reverse proxy (nginx, Caddy, Traefik, HAProxy, ALB) to terminate TLS and forward plain HTTP to the engine.
+- TLS certificates and their renewal. Wheels has an `HTTPS detection` helper but does not issue or rotate certificates.
+- Secrets management. The app reads from environment variables; how those variables get populated (1Password Connect, Vault, SSM Parameter Store, Docker secrets, plain `.env` files) is your call.
+- The database, its backups, and its failover story. Migrations run against whatever `WHEELS_DATASOURCE_*` points at — the framework does not provision databases.
+- Logs, metrics, and traces. See [Observability & Logging](/v4-0-0-snapshot/deployment/observability-and-logging/) for the hooks Wheels exposes.
+
+
+
+## The `wheels deploy` CLI
+
+There is no `wheels deploy` command in 4.0 today. Deployment is currently assembled from the pieces your infrastructure gives you — a Dockerfile and a `docker compose` or Kubernetes manifest, or an Ansible playbook and a systemd unit.
+
+A first-party `wheels deploy` is in active development. The design ports the [Kamal](https://kamal-deploy.org/) deploy model — single-binary, zero-downtime rolling deploys from a laptop, with the orchestration state kept on the target hosts — onto the Wheels CLI surface. It targets Docker-based deployments first, and treats the reverse proxy, TLS via Let's Encrypt, and rolling container replacement as built-in rather than user-provided.
+
+{/* TODO(phase-2c): swap this paragraph for a link to the wheels deploy PR/issue once opened */}
+
+Until it ships, everything on the detail pages assumes you are assembling deployment yourself.
+
+## Where to go next
+
+
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/observability-and-logging.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/observability-and-logging.mdx
new file mode 100644
index 0000000000..5f10bc8b88
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/observability-and-logging.mdx
@@ -0,0 +1,340 @@
+---
+title: Observability and logging
+description: Request IDs, structured logs, health checks, Sentry error tracking, and the external tools that plug into them.
+type: howto
+sidebar:
+ order: 6
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+This page shows you how to instrument a Wheels app so you can tell what it's doing in production. You'll enable the request ID middleware, correlate log lines across a request, add a health check your load balancer can poll, activate the Sentry package for error tracking, and decide what metrics and APM you want to layer on top. The framework's observability surface is narrow by design — everything beyond it is a JVM and an HTTP app you wire into whatever collectors you already run.
+
+**You'll learn:**
+
+- How to enable `wheels.middleware.RequestId` and thread the request ID through logs
+- Where Wheels writes logs by default and how to produce JSON-shaped lines a collector can parse
+- A minimal health-check action for `/healthz`
+- How to activate and configure the first-party Sentry package
+- Which metrics and APM options are external and where to hook them in
+
+
+
+## Enable request IDs
+
+`wheels.middleware.RequestId` generates one UUID per HTTP request, stores it on `request.wheels.requestId`, and writes it to the `X-Request-Id` response header. This is the single thread you pull to correlate a client's bug report, a load-balancer access log entry, your application log, and a Sentry event — they all reference the same ID.
+
+The middleware is not enabled by default. Add it to the global pipeline:
+
+```cfm {test:compile} title="config/settings.cfm"
+set(middleware = [
+ new wheels.middleware.RequestId(),
+ new wheels.middleware.SecurityHeaders()
+]);
+```
+
+The component is eight lines of real code: it calls `CreateUUID()`, stashes the value on the request scope, runs the rest of the pipeline, then writes the header on the way out. See `vendor/wheels/middleware/RequestId.cfc` line 12 (`local.requestId = CreateUUID();`) and line 20 (`cfheader(name = "X-Request-Id", value = local.requestId);`).
+
+Put it first in the pipeline. Every downstream middleware, controller, and error handler can then read `request.wheels.requestId` and log it alongside whatever they're doing.
+
+### Accept a client-supplied request ID (optional)
+
+If you terminate TLS in a reverse proxy that already stamps `X-Request-Id` (nginx's `$request_id`, an ALB-generated trace header, a CDN trace ID), you may want to honor the inbound value instead of generating a fresh one. The built-in middleware always generates; to trust an inbound header, write a small wrapper:
+
+```cfm {test:compile} title="app/middleware/InboundRequestId.cfc"
+component implements="wheels.middleware.MiddlewareInterface" output="false" {
+
+ public string function handle(required struct request, required any next) {
+ local.inbound = request.cgi.http_x_request_id ?: "";
+ local.id = Len(local.inbound) ? local.inbound : CreateUUID();
+
+ request.wheels.requestId = local.id;
+ local.response = arguments.next(arguments.request);
+
+ try {
+ cfheader(name = "X-Request-Id", value = local.id);
+ } catch (any e) {}
+
+ return local.response;
+ }
+}
+```
+
+Only do this when the header originates from infrastructure you control. An untrusted client header is an injection vector into your logs.
+
+## Structured logging
+
+Wheels does not ship a logging facade. Every CFML engine supports `writeLog()` and the framework uses it directly — the job runner writes to `wheels_jobs`, the error handler writes to `wheels-errors`, and you can write anywhere you want with a `file=` argument. Log files land in the engine's log directory (Lucee: `{server}/logs/`, Adobe: `cfusion/logs/`).
+
+The default line format is plain text, which is fine for tailing during development and awkward for a log collector to index. Wrap `writeLog` with a thin helper that emits JSON and stamps the request ID:
+
+```cfm {test:compile} title="app/lib/Log.cfc"
+component output="false" {
+
+ public void function info(required string message, struct context = {}) {
+ $write(arguments.message, "information", arguments.context);
+ }
+
+ public void function warn(required string message, struct context = {}) {
+ $write(arguments.message, "warning", arguments.context);
+ }
+
+ public void function error(required string message, struct context = {}) {
+ $write(arguments.message, "error", arguments.context);
+ }
+
+ private void function $write(
+ required string message,
+ required string level,
+ required struct context
+ ) {
+ local.payload = {
+ "ts": DateTimeFormat(Now(), "iso"),
+ "level": arguments.level,
+ "message": arguments.message,
+ "requestId": request.wheels.requestId ?: "",
+ "environment": get("environment")
+ };
+ StructAppend(local.payload, arguments.context, true);
+
+ writeLog(
+ text = SerializeJSON(local.payload),
+ type = arguments.level,
+ file = "application"
+ );
+ }
+}
+```
+
+Register it in the DI container so every controller and model can reach it:
+
+```cfm {test:compile} title="config/services.cfm"
+var di = injector();
+di.map("log").to("app.lib.Log").asSingleton();
+```
+
+Then from a controller:
+
+```cfm {test:compile} title="app/controllers/Orders.cfc (fragment)"
+component extends="Controller" {
+
+ function config() {
+ inject("log");
+ }
+
+ function create() {
+ order = model("Order").new(params.order);
+ if (order.save()) {
+ this.log.info("order.created", {orderId: order.id, total: order.total});
+ redirectTo(route="order", key=order.id);
+ } else {
+ this.log.warn("order.create_failed", {errors: order.allErrors()});
+ renderView(action="new");
+ }
+ }
+}
+```
+
+Each line on disk becomes a single JSON object: timestamp, level, message, request ID, environment, plus whatever you passed in `context`. A collector can parse and index it with no further transformation.
+
+## Ship logs to a central collector
+
+Wheels does not ship to anything. The app writes files; a sidecar process reads them and forwards. The machinery on the sender side lives outside the framework — pick whichever fits your stack:
+
+- **Loki / Grafana Agent** — scrape the log files with `promtail` or `grafana-agent`, parse the JSON, index on `requestId` and `level`.
+- **AWS CloudWatch Logs** — install the CloudWatch agent on the host (or use Fargate's built-in log driver in containers), point it at the log directory.
+- **Datadog / New Relic / Papertrail** — their host agents watch arbitrary log paths. Parsing JSON is a config toggle.
+- **Docker / Kubernetes** — run the engine with `logs/` mounted to stdout (or write directly to stdout via a custom appender) and let the orchestrator's logging driver collect everything.
+
+The framework's job is to write structured lines. The infrastructure's job is to read them. Keep the responsibilities separate and you can change collectors without touching app code.
+
+## A health check endpoint
+
+Wheels does not provide a built-in health check route. Add one — every load balancer, orchestrator, and uptime prober expects it. A minimal version pings the database and returns JSON:
+
+```cfm {test:compile} title="app/controllers/Health.cfc"
+component extends="Controller" {
+
+ function index() {
+ local.checks = {
+ "database": $checkDatabase(),
+ "environment": get("environment")
+ };
+
+ local.healthy = local.checks.database.ok;
+ local.status = local.healthy ? 200 : 503;
+
+ renderWith(
+ data = {
+ "status": local.healthy ? "ok" : "degraded",
+ "checks": local.checks,
+ "requestId": request.wheels.requestId ?: ""
+ },
+ statusCode = local.status
+ );
+ }
+
+ private struct function $checkDatabase() {
+ try {
+ queryExecute("SELECT 1", {}, {datasource = get("dataSourceName")});
+ return {ok: true};
+ } catch (any e) {
+ return {ok: false, error: e.message};
+ }
+ }
+}
+```
+
+Route it above the wildcard:
+
+```cfm {test:compile} title="config/routes.cfm"
+mapper()
+ .get(name="healthz", pattern="/healthz", to="health##index")
+ .resources("orders")
+ .wildcard()
+.end();
+```
+
+Point your load balancer at `/healthz` with a short timeout (1–2 seconds) and a low threshold (two failures = unhealthy). Add more checks as they become load-bearing — Redis reachability, the background job queue depth, external API pingability — but resist the urge to check everything. A health check that fails because a downstream analytics API is slow will cycle your pods for no reason.
+
+
+
+## Error tracking with the Sentry package
+
+The `wheels-sentry` package ships in `packages/sentry/` and captures exceptions with framework-aware context — controller name, action, request URL, tags, breadcrumbs, and optional user identity.
+
+### Activate the package
+
+Sentry is a first-party package that follows the standard `packages/` → `vendor/` activation model:
+
+```bash title="your shell"
+cp -r packages/sentry vendor/sentry
+wheels reload
+```
+
+On startup, `PackageLoader` discovers `vendor/sentry/package.json`, instantiates `Sentry.cfc`, and mixes its public methods (`sentryCapture`, `sentryMessage`, `sentrySetUser`, `sentryAddBreadcrumb`) into every controller. See `packages/sentry/package.json` — `"provides": {"mixins": "controller"}` — for the wiring.
+
+### Configure the DSN
+
+Set the DSN via environment variable or `config/settings.cfm`. The package reads `sentryDSN` first, then falls back to the `SENTRY_DSN` Java system environment variable (`packages/sentry/Sentry.cfc` lines 36–45):
+
+```cfm {test:compile} title="config/settings.cfm"
+set(sentryDSN = "https://your-key@o123.ingest.us.sentry.io/456");
+set(sentryIncludeHeaders = true);
+set(sentryIncludeServerContext = true);
+set(sentrySendDefaultPii = false);
+```
+
+Environment (release tag) comes from `application.wheels.environment`; release version comes from the `APP_VERSION` Java system environment variable. Set `APP_VERSION` in your deploy pipeline so Sentry can associate events with a specific build.
+
+### Capture unhandled errors
+
+The package's controller mixin captures errors you wrap in a `try/catch`, but unhandled exceptions that reach `app/events/onerror.cfm` need an explicit call. Wire Sentry into the error event:
+
+```cfm {test:compile} title="app/events/onerror.cfm"
+
+try {
+ if (structKeyExists(application, "sentry") && structKeyExists(local, "exception")) {
+ application.sentry.captureException(
+ exception: local.exception,
+ level: "error",
+ useThread: false,
+ showJavaStackTrace: true
+ );
+ }
+} catch (any sentryError) {
+ writeLog(text="Sentry capture failed: ##sentryError.message##", type="error", file="application");
+}
+
+```
+
+`useThread: false` matters — the error handler can fire during shutdown, and a threaded send may be killed before it flushes.
+
+### Attach user identity
+
+The package does not read user identity from the session automatically. Set it in a before filter on `Controller.cfc`:
+
+```cfm {test:compile} title="app/controllers/Controller.cfc"
+component extends="wheels.Controller" {
+
+ function config() {
+ filters(through="setSentryUser");
+ }
+
+ private function setSentryUser() {
+ if (structKeyExists(session, "currentUser")) {
+ sentrySetUser({
+ id: session.currentUser.id,
+ email: session.currentUser.email,
+ username: session.currentUser.name
+ });
+ }
+ }
+}
+```
+
+Every event captured during that request then carries the user context.
+
+### What the package covers
+
+- **Controller actions** — via the `sentryCapture()` mixin (explicit) or `onerror.cfm` wiring (unhandled).
+- **Breadcrumbs** — call `sentryAddBreadcrumb("loaded order")` anywhere in a request to leave trail crumbs on the next captured event.
+- **Tags** — every event is tagged with `wheels.controller`, `wheels.action`, `cfml.engine`, `wheels.environment`.
+- **Release tracking** — populated from `APP_VERSION`.
+
+### What it doesn't cover
+
+- **Background jobs** — `Job.cfc` writes failures to the `wheels_jobs` log file (`vendor/wheels/Job.cfc` lines 345 and 368) but does not push them to Sentry. If you want Sentry visibility on job failures, wrap `perform()` in a `try/catch` inside each job and call `application.sentry.captureException()` on the rescue path.
+- **Middleware** — exceptions thrown inside middleware bypass controller mixins. Use `application.sentry.captureException()` directly if you need coverage there.
+
+## Metrics and APM
+
+Wheels does not ship metrics. A Wheels app is a Java webapp on a JVM, so every JVM-targeted APM tool works unchanged:
+
+- **Datadog APM / New Relic / AppDynamics / Dynatrace** — install the JVM agent on the Lucee or Adobe process. Traces show up with HTTP spans, database spans, and the standard JVM metrics (GC, heap, thread pools). No framework integration needed.
+- **Prometheus** — expose JVM metrics via `jmx_exporter` as a Java agent, or run a sidecar that scrapes a metrics endpoint you build yourself (a controller action rendering Prometheus text format).
+- **StatsD / OpenTelemetry** — both have Java agents. OpenTelemetry specifically auto-instruments servlet requests, so you'll get HTTP-level traces without framework hooks.
+
+Pick the agent your platform team already runs. Framework-specific metrics (per-controller timings, per-model query counts) are not a framework concern — sample them at the JVM level instead.
+
+## What to alert on
+
+A small set of signals covers almost every production incident a Wheels app will have. Start with these and add more only when a specific incident teaches you to:
+
+- **5xx rate** — from the reverse proxy access log or the APM agent. Alert when the ratio of 5xx responses crosses a threshold (1% over five minutes is a reasonable starting point).
+- **Response time p95** — the tail, not the average. A p95 creeping from 200 ms to 1.5 s signals a problem before the average moves.
+- **Job queue depth** — `wheels jobs status` reports pending, processing, and failed counts per queue. Alert on pending counts that don't drain or failed counts that grow.
+- **Database connection pool saturation** — engine-specific metric. Lucee exposes datasource stats; Adobe does too. A pool pinned at its ceiling means requests are queuing to talk to the database.
+- **Health check failing** — your load balancer's view of app reachability. One pod occasionally failing is noise; all pods failing is an outage.
+- **Sentry error rate** — alert when the rate of captured exceptions for a release exceeds a baseline, or when a new error fingerprint appears post-deploy.
+
+Everything else — disk space, CPU, memory, certificate expiry — is infrastructure alerting and lives with your platform team's standard host checks.
+
+## Related guides
+
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/production-config.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/production-config.mdx
new file mode 100644
index 0000000000..9ca4da5c17
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/production-config.mdx
@@ -0,0 +1,191 @@
+---
+title: Production configuration
+description: Concrete settings, env-var handling, and a pre-boot checklist for running Wheels in production.
+type: howto
+sidebar:
+ order: 2
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+This page shows you what to set, what to override, and what to verify before a Wheels app takes real traffic. You'll configure `config/environment.cfm` and `config/production/settings.cfm`, wire secrets through `.env` and the process environment, and walk a pre-boot checklist that catches the failures most teams only notice at 3am.
+
+**You'll learn:**
+
+- How to flip an app into production mode and what that changes
+- Which settings are production-critical and what to override
+- How Wheels reads environment variables — `.env`, then the process environment
+- A ten-point checklist to run before first production boot
+
+
+
+## Flip the environment switch
+
+`config/environment.cfm` selects the active environment. The scaffold ships with `development` hardcoded:
+
+```cfm {test:compile} title="config/environment.cfm (scaffold default)"
+
+ set(environment="development");
+
+```
+
+In production, read `WHEELS_ENV` from the process instead so the same artifact can boot dev, staging, and prod:
+
+```cfm {test:compile} title="config/environment.cfm"
+
+ set(environment=application.wo.env("WHEELS_ENV", "production"));
+
+```
+
+Wheels boots, calls `config/environment.cfm`, and then loads `config/settings.cfm` followed by `config//settings.cfm`. Anything you `set()` in the per-environment file wins (`vendor/wheels/events/onapplicationstart.cfc:271-274`).
+
+What switching to `production` changes, automatically:
+
+- `showErrorInformation` → `false` — error pages stop leaking stack traces (`vendor/wheels/events/init/debugging.cfm:22-25`).
+- `sendEmailOnError` → `true` — set `errorEmailToAddress` or you'll get nothing.
+- `showDebugInformation` → `false` — the floating debug panel is hidden (`debugging.cfm:26-28`).
+- `cacheActions`, `cacheImages`, `cachePages`, `cachePartials`, `cacheQueries` → all `true` (`vendor/wheels/events/init/caching.cfm:15-21`).
+- `redirectAfterReload` → `true` — the reload URL 302s after success (`vendor/wheels/events/init/security.cfm:43-45`).
+- URL-based environment switching is disabled unless you explicitly re-enable it (`onapplicationstart.cfc:276-284`).
+- Migrations refuse to run `down` (`onapplicationstart.cfc:252-254` inverts only for development).
+
+You do not set any of those manually. Setting `environment` to `production` is the switch.
+
+## Configure `config/production/settings.cfm`
+
+Create `config/production/settings.cfm`. This file is read only when the active environment is `production`:
+
+```cfm {test:compile} title="config/production/settings.cfm"
+
+ // Real datasource — never H2 or SQLite in production.
+ set(dataSourceName=application.wo.env("WHEELS_DATASOURCE", "myapp_prod"));
+ set(dataSourceUserName=application.wo.env("WHEELS_DB_USER", ""));
+ set(dataSourcePassword=application.wo.env("WHEELS_DB_PASSWORD", ""));
+
+ // Rotate this per-deploy. Empty disables ?reload= entirely.
+ set(reloadPassword=application.wo.env("RELOAD_PASSWORD", ""));
+
+ // Persistent CSRF encryption key — required when csrfStore="cookie".
+ set(csrfCookieEncryptionSecretKey=application.wo.env("CSRF_KEY", ""));
+
+ // Error notifications.
+ set(errorEmailToAddress=application.wo.env("ERROR_EMAIL", "ops@example.com"));
+ set(errorEmailFromAddress="noreply@example.com");
+
+ // URL rewriting: "On" when your web server rewrites /foo → /index.cfm/foo.
+ set(URLRewriting="On");
+
+```
+
+Nothing in that block is invented. Each key is defined in `vendor/wheels/events/init/`:
+
+| Setting | Default | Source |
+| --- | --- | --- |
+| `dataSourceName` | app folder name | `onapplicationstart.cfc:217-224` |
+| `dataSourceUserName`, `dataSourcePassword` | `""` | `events/init/orm.cfm:2-3` |
+| `reloadPassword` | `""` | `events/init/security.cfm:25` |
+| `csrfCookieEncryptionSecretKey` | `""` | `events/init/security.cfm:5` |
+| `errorEmailToAddress`, `errorEmailFromAddress` | `""` | `events/init/debugging.cfm:8-9` |
+| `URLRewriting` | detected from CGI | `onapplicationstart.cfc:207-215` |
+
+
+
+## How Wheels reads environment variables
+
+`application.wo.env("NAME", "default")` is the canonical accessor. It checks `application.env` first, then `server.system.environment`, then returns the default (`vendor/wheels/Global.cfc:410-422`).
+
+`application.env` is populated by the scaffold's `Application.cfc` at `onApplicationStart()`:
+
+1. Reads `.env` at the app root (`cli/lucli/templates/app/public/Application.cfc:52-56`).
+2. Resolves `WHEELS_ENV` from `.env` first, then `java.lang.System.getenv("WHEELS_ENV")` (`Application.cfc:58-73`).
+3. Reads `.env.` on top if present — so `.env.production` overrides `.env` (`Application.cfc:75-81`).
+4. Interpolates `${VAR}` references between entries (`Application.cfc:84`).
+5. Copies the merged struct to `application.env` (`Application.cfc:92`).
+
+The practical rule: **commit nothing secret**. Ship `.env.example` with empty values, inject real values at deploy time. A deploy script, systemd `EnvironmentFile=`, Dockerfile `ENV`, or Kubernetes secret-mount all work.
+
+```sh title=".env.production (deploy-only, never committed)"
+WHEELS_ENV=production
+WHEELS_DATASOURCE=myapp_prod
+WHEELS_DB_USER=myapp
+WHEELS_DB_PASSWORD=xxxxxxxxxx
+RELOAD_PASSWORD=rotate-me-per-deploy
+CSRF_KEY=base64-aes-key-from-secrets-manager
+ERROR_EMAIL=ops@example.com
+```
+
+## Switching between dev, staging, and prod
+
+Three mechanisms, in order from safest to most dangerous:
+
+### Per-environment artifacts (recommended)
+
+Each deploy target exports `WHEELS_ENV` before the CFML server boots. `config/environment.cfm` reads it, the right per-env settings file loads, and that's it. Restart required to change environments.
+
+### URL-based reload (development only)
+
+`?reload=true&password=` re-runs `onApplicationStart`. `?reload=production&password=` switches the running app into production (`onapplicationstart.cfc:147-175`).
+
+Wheels disables URL-based switching **automatically** in production, testing, and maintenance environments unless you explicitly `set(allowEnvironmentSwitchViaUrl=true)` in your per-env settings (`onapplicationstart.cfc:276-284`). Leave it disabled. A cached reload password plus URL switching plus production is a bad combination.
+
+The reload password uses a constant-time comparison (`onapplicationstart.cfc:157-160`) and rate-limits failed attempts (`onapplicationstart.cfc:177-192`). Rotate it on every production deploy anyway — it's trivial to rotate and the value ends up in nginx logs if someone misconfigures TLS termination.
+
+### The `maintenance` environment
+
+`set(environment="maintenance")` puts Wheels into planned-downtime mode — `EventMethods.cfc:173` checks this to render a maintenance page instead of running the normal request lifecycle. Use it for long migrations or config rollouts.
+
+## Pre-boot checklist
+
+Run through this before the first production request. Each item maps to a framework check — if you skip it, Wheels won't always fail loudly.
+
+
+
+1. **`environment` is `production`.** Verify with `?controller=wheels&action=info` (dev only) or by checking `application.wheels.environment` in a smoke-test action. If it says `development`, `showErrorInformation` is still `true` and caching is off.
+2. **`reloadPassword` is set and rotated.** Empty disables `?reload=` entirely — Wheels writes a `wheels_security` warning log on boot if it's blank (`onapplicationstart.cfc:286-291`). Set it to a random value unique to this deploy.
+3. **`showErrorInformation` is `false`.** The `production` switch flips this, but a stray `set(showErrorInformation=true)` in `config/settings.cfm` will leak stack traces. Grep for it.
+4. **`dataSourceName` points at a real database.** Not H2, not `wheelstestdb`, not a shared staging instance. Confirm with a read-only action that calls `model("User").count()` or similar.
+5. **`csrfCookieEncryptionSecretKey` is set** if `csrfStore="cookie"` (the default when `session` scope isn't enabled). Otherwise first CSRF check throws `Wheels.Security.MissingCsrfKey` (`controller/csrf.cfc:152-161`).
+6. **Session storage matches the node topology.** `session` flash storage (`events/init/security.cfm:49-55`) requires sticky sessions or a session-replication setup behind the load balancer. With multiple nodes and no stickiness, use `flashStorage="cookie"` explicitly.
+7. **Error email is wired.** `sendEmailOnError` is auto-enabled in production (`debugging.cfm:22-25`). If `errorEmailToAddress` is empty, Wheels still tries to send — `events/EventMethods.cfc:5-13` reads the address at error time — and the mail bounces silently. Set it.
+8. **URL rewriting matches the web server.** `set(URLRewriting="On")` only works if nginx/Apache/Tuckey rewrites `/posts/1` to `/index.cfm/posts/1`. If rewrites aren't configured, leave it at `"Partial"` or `"Off"`, or every link breaks.
+9. **`allowEnvironmentSwitchViaUrl` is not re-enabled.** Confirm by grepping `config/production/settings.cfm` — don't set it to `true`. If you need to toggle environments, redeploy.
+10. **Caches are warmed or warm fast.** First request after deploy compiles every controller/model. For latency-sensitive endpoints, hit them from the deploy script before flipping the load balancer. `cacheControllerConfig`, `cacheModelConfig`, `cacheDatabaseSchema`, and `cacheFileChecking` are always-on in every environment (`events/init/caching.cfm:3-7`), so a single warm-up request per controller is usually enough.
+
+## What production does not change
+
+A few things you might expect to flip, that don't:
+
+- **Routes are not cached.** `config/routes.cfm` is re-evaluated on every reload. There's no "compiled routes" mode — route dispatch is cheap enough that it doesn't matter.
+- **Migrations are not auto-run.** `autoMigrateDatabase` is `false` in every environment (`onapplicationstart.cfc:244`). Run `wheels dbmigrate latest` from your deploy script explicitly.
+- **`.env` files are not required.** If you don't ship one, `application.env` stays empty and `application.wo.env()` falls through to `server.system.environment`. That's fine — it's why systemd `EnvironmentFile=` and Docker `ENV` both work out of the box.
+
+## Related guides
+
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/security-hardening.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/security-hardening.mdx
new file mode 100644
index 0000000000..dc1bb72c8b
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/security-hardening.mdx
@@ -0,0 +1,272 @@
+---
+title: Security hardening
+description: Security configuration Wheels apps should ship to production with — SecurityHeaders, CSRF, HSTS, reload password, secrets, error pages.
+type: howto
+sidebar:
+ order: 5
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+This page shows you the framework-provided security primitives a Wheels app should turn on before it goes to production. You'll register the `SecurityHeaders` middleware, decide which opt-in headers (CSP, HSTS, Permissions-Policy) to emit, wire CSRF protection into a base controller, rotate the reload password and datasource secrets out of source, handle real-client IPs from behind a load balancer, and turn off verbose error pages.
+
+**You'll learn:**
+
+- The full set of headers `wheels.middleware.SecurityHeaders` sets, with their defaults
+- How Wheels enforces CSRF via `protectsFromForgery()`, `authenticityTokenField()`, and `csrfMetaTags()`
+- When to disable CSRF per-action (token-authenticated API endpoints)
+- How to handle HSTS, TLS termination, and `X-Forwarded-For` behind a reverse proxy
+- How to keep `reloadPassword` and datasource credentials out of source control
+- How to disable `showErrorInformation` for production
+
+
+
+## Register SecurityHeaders
+
+Register `SecurityHeaders` globally in `config/settings.cfm`:
+
+```cfm {test:compile} title="config/settings.cfm"
+
+set(middleware = [
+ new wheels.middleware.SecurityHeaders()
+]);
+
+```
+
+With no arguments, the middleware writes four headers on every response and — in production — adds a fifth. Content-Security-Policy and Permissions-Policy stay off unless you configure them.
+
+## Headers and defaults
+
+Verified against `vendor/wheels/middleware/SecurityHeaders.cfc`:
+
+| Constructor arg | Header | Default | Source |
+|-----------------|--------|---------|--------|
+| `frameOptions` | `X-Frame-Options` | `SAMEORIGIN` | line 24, 54 |
+| `contentTypeOptions` | `X-Content-Type-Options` | `nosniff` | line 25, 57 |
+| `xssProtection` | `X-XSS-Protection` | `1; mode=block` | line 26, 60 |
+| `referrerPolicy` | `Referrer-Policy` | `strict-origin-when-cross-origin` | line 27, 63 |
+| `contentSecurityPolicy` | `Content-Security-Policy` | `""` (opt-in) | line 28, 65-67 |
+| `strictTransportSecurity` | `Strict-Transport-Security` | `""` — auto-resolves to `max-age=31536000; includeSubDomains` in production | line 29, 48-51, 68-70 |
+| `permissionsPolicy` | `Permissions-Policy` | `""` (opt-in) | line 30, 71-73 |
+| `environment` | — | `""` — falls back to `application.$wheels.environment` | line 31, 37-45 |
+
+Setting any value to an empty string suppresses that header. `frameOptions=""` drops `X-Frame-Options`; the same pattern works for every argument.
+
+## Configure CSP and Permissions-Policy
+
+`contentSecurityPolicy` and `permissionsPolicy` are empty by default because a restrictive policy can break apps with inline scripts, styles, or third-party embeds. Opt in explicitly once you know what your app loads:
+
+```cfm {test:compile} title="config/settings.cfm"
+
+set(middleware = [
+ new wheels.middleware.SecurityHeaders(
+ contentSecurityPolicy="default-src 'self'; img-src 'self' data:; script-src 'self'",
+ permissionsPolicy="geolocation=(), camera=(), microphone=()"
+ )
+]);
+
+```
+
+Test both with the browser console open — CSP violations log to the console, Permissions-Policy blocks silently but is visible in `Permissions-Policy` response headers.
+
+## HSTS and TLS termination
+
+HSTS tells browsers to force HTTPS for your hostname for `max-age` seconds. The middleware emits the default `max-age=31536000; includeSubDomains` automatically when the environment is `production` and you didn't pass a value.
+
+Three deployment cases:
+
+- **HTTPS-only app, TLS terminated by Wheels or a pass-through proxy.** Leave HSTS on. Terminate TLS once, redirect `http://` to `https://` at the reverse proxy (nginx, Caddy, ALB), and let the middleware emit the header.
+- **Load balancer terminates TLS and already sets HSTS.** You'll get duplicate headers. Browsers accept duplicates only if the values match — pass an explicit `strictTransportSecurity` matching what the proxy emits, or suppress the header at the proxy.
+- **Local development or HTTP traffic.** HSTS shouldn't appear. Because the middleware keys off `application.$wheels.environment`, a non-production environment already omits the header.
+
+### `preload` tradeoffs
+
+Adding `preload` to the directive opts your domain into the browser-maintained HSTS preload list. Once baked into Chrome/Firefox/Safari (weeks after submission), every browser forces HTTPS for the domain even on first visit — and removing the domain from the preload list takes months. Only opt in once you are certain every subdomain serves HTTPS:
+
+```cfm title="illustrative — do not type"
+new wheels.middleware.SecurityHeaders(
+ strictTransportSecurity="max-age=63072000; includeSubDomains; preload"
+)
+```
+
+
+
+## CSRF protection
+
+Wheels ships CSRF protection in `vendor/wheels/controller/csrf.cfc`. Turn it on once in your base controller — every other controller inherits it.
+
+```cfm {test:compile} title="app/controllers/Controller.cfc"
+component extends="wheels.Controller" {
+ function config() {
+ protectsFromForgery();
+ }
+}
+```
+
+`protectsFromForgery()` takes three optional arguments:
+
+| Argument | Default | Meaning |
+|----------|---------|---------|
+| `with` | `"exception"` | How to handle a missing/invalid token. `"exception"` throws `Wheels.InvalidAuthenticityToken`, `"abort"` sends an empty response and aborts, `"ignore"` lets the request through. |
+| `only` | `""` | Comma-delimited list of actions to check. Empty means all actions. |
+| `except` | `""` | Comma-delimited list of actions to skip. |
+
+### The enforcement path
+
+1. **View** — add a hidden token field to any `POST`/`PUT`/`PATCH`/`DELETE` form with `authenticityTokenField()` (view helper in `vendor/wheels/view/csrf.cfc`, line 26). Form helpers like `startFormTag()` insert it automatically for non-GET forms.
+2. **View (AJAX)** — add `csrfMetaTags()` to your layout `` (line 10 of the same file). It emits two `` tags your JavaScript reads to send the token as an `X-CSRF-Token` header.
+3. **Controller pipeline** — before each action runs, `$runCsrfProtection(action)` (`vendor/wheels/controller/csrf.cfc` line 37) checks the `only`/`except` filters, calls `$verifyAuthenticityToken()` (line 63), and throws, aborts, or ignores per the `with` setting.
+4. **Verification** — `$isVerifiedRequest()` (line 82) short-circuits on GET/HEAD/OPTIONS; otherwise it matches `params.authenticityToken` against the session-stored token (`CsrfVerifyToken`) or the cookie-stored token depending on `application.wheels.csrfStore` (session by default, see `vendor/wheels/events/init/security.cfm` line 3).
+
+### Disable per-action for API endpoints
+
+Token-authenticated API endpoints don't use cookies, so CSRF adds nothing. Skip the check for API actions:
+
+```cfm {test:compile} title="app/controllers/Api.cfc"
+component extends="Controller" {
+ function config() {
+ protectsFromForgery(except="create,update,delete");
+ provides("json");
+ }
+
+ function create() {
+ // Auth is handled by a Bearer token filter; CSRF is off for this action.
+ }
+}
+```
+
+For an entire JSON API under `/api`, inherit from a controller that calls `protectsFromForgery(with="ignore")` instead of the default exception-throwing base.
+
+## Reload password
+
+`reloadPassword` gates `?reload=true` and environment switches via URL (`?reload=production`). If it's empty, both are disabled and Wheels logs a security warning on boot (`vendor/wheels/events/onapplicationstart.cfc` line 287-290). If it's set, attempts log to `wheels_security.log` with the source IP (line 189, 197) and rate-limit by IP (line 135).
+
+```cfm {test:compile} title="config/settings.cfm"
+
+set(dataSourceName="myapp");
+set(reloadPassword=application.wo.env("WHEELS_RELOAD_PASSWORD"));
+
+```
+
+Rotate it in every environment. The scaffolded template uses `{{reloadPassword}}` as a placeholder the generator fills in (`cli/lucli/templates/app/config/settings.cfm` line 25); replace with an env-var lookup before you ship. Never commit the literal value.
+
+## Secret management
+
+Keep every runtime secret out of source control:
+
+- **`.env` files in dev.** `application.wo.env("NAME")` reads from the process environment, which Wheels populates from `.env` at boot. Add `.env` to `.gitignore`; commit `.env.example` with the keys (no values).
+- **Environment variables in prod.** Set secrets via the deploy tool (systemd `EnvironmentFile=`, Kubernetes `Secret`, Docker `--env-file`, 1Password Connect, AWS Parameter Store, etc.).
+- **Never bake secrets into an image.** A container image is a public artifact — treat it that way.
+
+The datasource is a common miss. `set(dataSourcePassword="hunter2")` in `config/settings.cfm` ends up in git blame. Read it from the environment instead:
+
+```cfm {test:compile} title="config/settings.cfm"
+
+set(dataSourceName="myapp");
+set(dataSourceUserName=application.wo.env("DB_USER"));
+set(dataSourcePassword=application.wo.env("DB_PASSWORD"));
+
+```
+
+## Trusted proxies and client IP
+
+Wheels does **not** trust `X-Forwarded-For` by default. `cgi.remote_addr` is the last-hop address — your load balancer, not the end user. Two framework surfaces touch this:
+
+- **`RateLimiter` middleware.** Opt into proxy trust explicitly with `trustProxy=true`. The default is `false` "for security" (`vendor/wheels/middleware/RateLimiter.cfc` line 20, 51). Your proxy **must** strip any incoming `X-Forwarded-For` and append the real client, otherwise attackers can spoof an arbitrary IP and bypass rate limits.
+- **Application-level logging.** Nothing in Wheels reads `X-Forwarded-For` automatically for `cgi.remote_addr`. If you want the real client IP in logs, configure your reverse proxy to overwrite `CF.REMOTE_ADDR` at the connector (`mod_cfml`, Tuckey rewriter, nginx `proxy_set_header`) before the request reaches the engine.
+
+```cfm {test:compile} title="config/settings.cfm — rate limiter with trusted proxy"
+
+set(middleware = [
+ new wheels.middleware.SecurityHeaders(),
+ new wheels.middleware.RateLimiter(
+ maxRequests=100,
+ windowSeconds=60,
+ trustProxy=true,
+ proxyStrategy="last"
+ )
+]);
+
+```
+
+`proxyStrategy="last"` takes the rightmost IP in `X-Forwarded-For` (added by the nearest trusted proxy). Use `"first"` only if your proxy chain guarantees the client is always the leftmost entry.
+
+## Rate limiting
+
+`wheels.middleware.RateLimiter` is your first line of defense against credential stuffing, scraping, and accidental self-DoS from a runaway client. It adds `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers and responds `429` with `Retry-After` when the bucket is empty. See [Rate Limiting](/v4-0-0-snapshot/digging-deeper/rate-limiting/) for strategies, storage backends, and key functions.
+
+## CORS
+
+Cross-origin restrictions are a browser-enforced policy, not a server-enforced one — but your server has to opt in for the browser to let requests through. Use `wheels.middleware.Cors` with an explicit origin allowlist; never wildcard `*` on credentialed endpoints. Full reference: [CORS](/v4-0-0-snapshot/digging-deeper/cors/).
+
+## Production error pages
+
+Wheels renders a detailed error page with stack traces, parameter dumps, and file paths when `showErrorInformation=true`. That's fine locally and catastrophic in production — it leaks source paths, CFML versions, and sometimes credentials from the param dump.
+
+The framework already does the right thing by default: `vendor/wheels/events/init/debugging.cfm` line 22-25 flips both `showErrorInformation` and `sendEmailOnError` when the environment is `production`. Verify for your app:
+
+```cfm {test:compile} title="config/production/settings.cfm"
+
+set(showErrorInformation=false);
+set(showDebugInformation=false);
+set(sendEmailOnError=true);
+set(errorEmailToAddress="ops@example.com");
+
+```
+
+Configure a custom error page (usually in `app/events/onerror.cfm`) to render a friendly page instead of a raw CFML error.
+
+## Dependency monitoring
+
+Wheels does not ship automated vulnerability scanning. `wheels doctor` (`cli/lucli/services/Doctor.cfc`) checks project structure and config — required directories, datasource presence, migration count, test coverage — it does **not** audit dependencies or security settings.
+
+For dependency and engine monitoring:
+
+- Subscribe to [Lucee security advisories](https://lucee.org/security.html) and apply engine updates promptly.
+- Track Adobe ColdFusion security bulletins if you target it.
+- Pin package versions in `box.json` and audit third-party packages in `packages/` and `vendor/` during routine dependency reviews.
+
+## Production checklist
+
+- [ ] `SecurityHeaders` middleware registered globally
+- [ ] `Content-Security-Policy` configured (opt-in) and tested with browser console
+- [ ] `Strict-Transport-Security` emitted by exactly one layer (middleware or proxy), not both
+- [ ] HTTP-to-HTTPS redirect at the reverse proxy
+- [ ] `protectsFromForgery()` in base controller; API controllers opt out explicitly
+- [ ] `reloadPassword` set via env var, never committed
+- [ ] Datasource credentials read from env, never in `config/settings.cfm`
+- [ ] `.env` in `.gitignore`; `.env.example` committed without values
+- [ ] `RateLimiter` middleware on public write endpoints
+- [ ] `Cors` middleware with an explicit origin allowlist if serving cross-origin
+- [ ] `showErrorInformation=false` and `showDebugInformation=false` in production
+- [ ] `trustProxy` set correctly on `RateLimiter` for your proxy topology
+- [ ] Lucee/Adobe security bulletins subscribed
+
+## Related guides
+
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/vm-deployment.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/vm-deployment.mdx
new file mode 100644
index 0000000000..487a986965
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/vm-deployment.mdx
@@ -0,0 +1,375 @@
+---
+title: VM and bare-metal deployment
+description: Deploy a Wheels app to a Linux VM with Lucee 7, systemd, nginx, and a zero-downtime symlink swap.
+type: howto
+sidebar:
+ order: 4
+---
+
+import { Aside, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components';
+
+This page shows you how to run a Wheels app on a traditional Linux VM — no containers, no orchestrator, just Lucee under systemd with nginx in front. You'll install Lucee 7, lay out the app under `/opt/myapp`, write a systemd unit, terminate TLS in nginx, and run a zero-downtime release swap with `rsync` and a symlink.
+
+**You'll learn:**
+
+- How to install Lucee 7 and Java 21 on a Linux VM and run the app as a service
+- How to lay out releases so a new deploy is a single `ln -sfn` swap away from live
+- How to terminate TLS in nginx, reverse-proxy to Lucee on loopback, and rotate logs
+
+
+
+## Why deploy to a VM
+
+Docker is the default for most new Wheels deploys, but plenty of teams still ship onto VMs or bare metal. Pick this path when:
+
+- **Regulatory or audit constraints** require a named host, a disk-encrypted volume, and no shared container runtime.
+- **Your ops team runs everything on VMs already** — monitoring agents, backup hooks, configuration management. A container is a new pane of glass they don't want.
+- **There's no Docker host available** — a hosted VPS, a colo box, an AS/400 shop's first Linux VM.
+- **Lift-and-shift from a legacy CF host** — the old app ran as a service on a box; the new one can too, with minimal change.
+
+The trade-off is manual work at setup and upgrade time. You install the JRE, you install Lucee, you write the systemd unit. In exchange you get a long-lived host you can SSH into, inspect, and reason about exactly the way you always have.
+
+## Prerequisites
+
+- A Linux VM with root access. Ubuntu 22.04 LTS or Rocky 10 are good defaults.
+- A domain name with an `A` record pointing at the VM's public IP.
+- Ports 80 and 443 open inbound; everything else closed.
+- Java 21. Lucee 7 runs on Java 21 — confirmed by the Wheels test image, which pins `lucee@7.0.1+100` ([`tools/docker/lucee7/Dockerfile`](https://github.com/wheels-dev/wheels/blob/develop/tools/docker/lucee7/Dockerfile)).
+
+Install the JDK from your distro's package manager:
+
+```bash
+# Ubuntu / Debian
+sudo apt-get update
+sudo apt-get install -y openjdk-21-jre-headless rsync nginx logrotate
+
+# Rocky / RHEL
+sudo dnf install -y java-21-openjdk-headless rsync nginx logrotate
+```
+
+Verify:
+
+```bash
+java -version
+# openjdk version "21.0.x" ...
+```
+
+## Install Lucee 7
+
+Lucee 7 is the canonical engine target for Wheels 4. Two install options work equally well for a VM deployment.
+
+**Option A — official installer.** Download the Linux installer from the [Lucee downloads page](https://download.lucee.org/) and run it. The installer lays Lucee down under `/opt/lucee` (or a path you pick), installs a Tomcat servlet container, and generates an init/systemd service. This is the shortest path if you want Lucee's built-in HTTP listener and admin console without extra wiring.
+
+**Option B — tarball drop-in.** Download the Lucee tarball (JAR + lib/), extract to `/opt/lucee`, and run it directly with `java -jar`. This gives you fewer moving parts — no bundled Tomcat, no installer-managed service — at the cost of writing the systemd unit yourself. For a single-purpose Wheels VM, this is usually what you want.
+
+Follow the Lucee install docs end-to-end rather than re-typing them here — they're the authoritative source and they change with each release. See [Lucee installation guides](https://docs.lucee.org/guides/installing-lucee/).
+
+
+
+After install, confirm Lucee is listening on a local port (`8888` is a common default) and that a `lucee.sh` (or equivalent) start script exists in `/opt/lucee/bin`.
+
+
+
+## App directory layout
+
+Lay the app out under `/opt/myapp` so releases are immutable, shared state lives outside any release, and `current` is a symlink you swap atomically.
+
+```
+/opt/myapp/
+├── current -> releases/20260421T143022Z (symlink)
+├── releases/
+│ ├── 20260418T081500Z/
+│ ├── 20260420T101200Z/
+│ └── 20260421T143022Z/
+└── shared/
+ ├── .env
+ ├── logs/
+ ├── uploads/
+ └── sessions/
+```
+
+- **`releases/`** — one directory per deploy. Contains the full app tree (`app/`, `config/`, `public/`, `vendor/`, etc.). Immutable once rsynced.
+- **`current`** — a symlink pointing at the active release. Lucee and nginx both reference this path; swapping the symlink swaps the live app.
+- **`shared/`** — everything that must survive across releases. Logs, user uploads, persisted sessions on disk, and the `.env` file containing secrets. Each new release symlinks these paths back into itself before going live.
+
+Create the layout once:
+
+```bash
+sudo mkdir -p /opt/myapp/releases /opt/myapp/shared/{logs,uploads,sessions}
+sudo chown -R lucee:lucee /opt/myapp
+```
+
+Put the production `.env` in `/opt/myapp/shared/.env` and lock its permissions:
+
+```bash
+sudo install -m 600 -o lucee -g lucee /dev/stdin /opt/myapp/shared/.env <<'EOF'
+WHEELS_ENV=production
+DB_PASSWORD=...
+RELOAD_PASSWORD=...
+EOF
+```
+
+Wheels reads these at boot via `application.wo.env("VAR_NAME")`. See [Environments and Configuration](/v4-0-0-snapshot/core-concepts/environments-and-configuration/#secrets-handling) for where that lookup happens.
+
+## systemd unit
+
+Run Lucee as a systemd service so it starts on boot, restarts on crash, and logs through journald. Create `/etc/systemd/system/myapp.service`:
+
+```ini
+[Unit]
+Description=MyApp Wheels application (Lucee 7)
+After=network.target
+
+[Service]
+Type=simple
+User=lucee
+Group=lucee
+WorkingDirectory=/opt/myapp/current
+EnvironmentFile=/opt/myapp/shared/.env
+ExecStart=/opt/lucee/bin/lucee.sh run
+ExecStop=/opt/lucee/bin/lucee.sh stop
+Restart=on-failure
+RestartSec=5
+LimitNOFILE=65536
+StandardOutput=append:/opt/myapp/shared/logs/lucee.out.log
+StandardError=append:/opt/myapp/shared/logs/lucee.err.log
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Key pieces:
+
+- `EnvironmentFile=` loads the `.env` into the service's environment. `WHEELS_ENV=production` is what flips Wheels into [production mode](/v4-0-0-snapshot/core-concepts/environments-and-configuration/#environment-detection).
+- `WorkingDirectory=/opt/myapp/current` is the symlink — systemd re-resolves it on every restart, so a deploy swap picks up the new release without editing the unit.
+- `Restart=on-failure` with `RestartSec=5` gives the process five seconds between restarts. If your init script does a graceful shutdown, also set `TimeoutStopSec=30`.
+- `User=lucee` runs the process as an unprivileged account. Don't run Lucee as root. Create the user up front: `sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin lucee`.
+
+Enable and start:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now myapp
+sudo systemctl status myapp
+```
+
+Lucee should now be listening on `127.0.0.1:8888` (or whatever port your install uses). Curl it locally to confirm:
+
+```bash
+curl -I http://127.0.0.1:8888/
+```
+
+## nginx reverse proxy
+
+nginx terminates TLS, serves static assets out of `public/`, and proxies everything else to Lucee on loopback. Create `/etc/nginx/sites-available/myapp.conf`:
+
+```nginx
+upstream myapp_lucee {
+ server 127.0.0.1:8888;
+ keepalive 32;
+}
+
+server {
+ listen 80;
+ server_name myapp.example.com;
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name myapp.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ root /opt/myapp/current/public;
+
+ # Serve static files directly; fall back to Lucee for dynamic requests.
+ location / {
+ try_files $uri @lucee;
+ }
+
+ location @lucee {
+ proxy_pass http://myapp_lucee;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_read_timeout 60s;
+ }
+
+ access_log /opt/myapp/shared/logs/nginx.access.log;
+ error_log /opt/myapp/shared/logs/nginx.error.log;
+}
+```
+
+Enable the site and reload:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+Get a TLS cert with Let's Encrypt:
+
+```bash
+sudo certbot --nginx -d myapp.example.com
+```
+
+Certbot edits the nginx config to wire the cert paths and installs a renewal timer. `sudo certbot renew --dry-run` verifies renewal works before the cert actually expires.
+
+
+
+## Firewall
+
+Only 443 (and 80 for the Let's Encrypt redirect) should be open to the internet. Lucee stays on loopback.
+
+```bash
+# Ubuntu / Debian (ufw)
+sudo ufw allow 22/tcp
+sudo ufw allow 80/tcp
+sudo ufw allow 443/tcp
+sudo ufw enable
+
+# Rocky / RHEL (firewalld)
+sudo firewall-cmd --permanent --add-service=http
+sudo firewall-cmd --permanent --add-service=https
+sudo firewall-cmd --reload
+```
+
+Lucee's own HTTP listener binds to `127.0.0.1` by convention — confirm in your Lucee/CommandBox server config. A Lucee listener on `0.0.0.0:8888` behind an open firewall exposes the admin panel to the world.
+
+## Zero-downtime deploy
+
+The release swap is three steps on the VM: rsync the new code in, run migrations, flip the symlink. nginx gets a `reload` so it re-resolves the symlink; systemd gets nothing — Lucee keeps serving out of the old release until the next restart.
+
+From your build machine or CI runner:
+
+
+
+1. **Build a release locally.** Commit, `git archive`, or checkout into a clean working directory. Make sure `vendor/` contains any production dependencies your app needs.
+
+2. **rsync to a new timestamped directory.**
+
+ ```bash
+ RELEASE=$(date -u +%Y%m%dT%H%M%SZ)
+ rsync -az --delete \
+ --exclude='.env' \
+ --exclude='tests/' \
+ ./ lucee@myapp.example.com:/opt/myapp/releases/$RELEASE/
+ ```
+
+3. **Wire the shared paths in.** On the VM, replace per-release log/upload/session directories and `.env` with symlinks into `shared/`.
+
+ ```bash
+ ssh lucee@myapp.example.com bash -s <
+
+**Rolling back** is the swap in reverse — `ln -sfn /opt/myapp/releases/ /opt/myapp/current` and reload. If the failed deploy ran a migration, roll the migration back first (`wheels dbmigrate down`) or accept that the older code is running against a newer schema.
+
+## Log rotation
+
+Lucee and nginx both write into `/opt/myapp/shared/logs/`. Rotate them weekly with logrotate. Create `/etc/logrotate.d/myapp`:
+
+```ini
+/opt/myapp/shared/logs/*.log {
+ weekly
+ rotate 8
+ compress
+ delaycompress
+ missingok
+ notifempty
+ copytruncate
+ su lucee lucee
+}
+```
+
+`copytruncate` avoids the dance of notifying Lucee to re-open its log files — logrotate copies the current log, then truncates the original. Good enough for most deployments. If you care about missing zero bytes at rotation time, switch to a rotation pattern that signals the app.
+
+## Upgrading Lucee in place
+
+Point releases within Lucee 7 are usually drop-in replacements. The rough shape:
+
+
+
+1. Back up `/opt/lucee` — a plain `tar -czf lucee-backup.tgz /opt/lucee` is enough.
+2. Stop the app: `sudo systemctl stop myapp`.
+3. Install the new Lucee version on top — either run the installer and point it at the same `/opt/lucee`, or extract a new tarball.
+4. Start the app: `sudo systemctl start myapp`. Watch logs: `journalctl -u myapp -f`.
+5. If anything misbehaves, stop the service, restore the backup tarball over `/opt/lucee`, and start again.
+
+
+
+Major-version upgrades (Lucee 6 → 7, 7 → 8) deserve a staging run first. See the [Lucee upgrade docs](https://docs.lucee.org/guides/upgrading-lucee/) for release-specific notes and the current compatibility matrix.
+
+## Related guides
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/glossary.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/glossary.mdx
index 065699d08f..5aaedb8815 100644
--- a/web/sites/guides/src/content/docs/v4-0-0-snapshot/glossary.mdx
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/glossary.mdx
@@ -1,9 +1,165 @@
---
title: Glossary
-description: Wheels terminology, one-line definitions.
+description: Wheels terminology, one-line definitions linked to the guide pages that explain them in full.
type: reference
sidebar:
order: 10
---
-Placeholder — content lands in Phase 2.
+Terms that carry a specific Wheels meaning. Each entry links to the guide page where the concept is explained in full.
+
+## A
+
+**Action.** A public method on a controller that handles one request — reads `params`, calls models, picks a response. See [MVC in Wheels](/v4-0-0-snapshot/core-concepts/mvc-in-wheels/) and [Controllers and Actions](/v4-0-0-snapshot/basics/controllers-and-actions/).
+
+**ActiveRecord.** The ORM pattern Wheels implements — one class per table, one instance per row, behavior attached to the row itself (≈ Rails: ActiveRecord). See [ORM Philosophy](/v4-0-0-snapshot/core-concepts/orm-philosophy/).
+
+**Association.** A declared relationship between two models (`hasMany`, `belongsTo`, `hasOne`) that generates dynamic methods for navigating between records. See [Associations](/v4-0-0-snapshot/basics/associations/).
+
+**Auto-wiring.** The DI container's no-args fallback — when a registered component's `init()` takes parameters matching other registered names, the container resolves and injects them automatically. See [The Dependency Injection Container](/v4-0-0-snapshot/core-concepts/dependency-injection/).
+
+## B
+
+**Backoff.** The delay between retry attempts on a failed job. Wheels uses exponential backoff — `Min(baseDelay * 2^attempt, maxDelay)`. See [Background Jobs](/v4-0-0-snapshot/digging-deeper/background-jobs/).
+
+**Batch processing.** Memory-safe iteration over large result sets via `findEach` (record at a time) or `findInBatches` (chunk at a time). See [Query Builder and Scopes](/v4-0-0-snapshot/basics/query-builder-and-scopes/).
+
+**`beforeAction` filter.** A private controller method registered to run before an action — the place for authentication, record loading, and ownership checks. See [The Request Lifecycle](/v4-0-0-snapshot/core-concepts/request-lifecycle/).
+
+## C
+
+**Callback.** A lifecycle hook on a model (`beforeSave`, `afterCreate`, `afterFind`, and the rest) registered in `config()` and run around persistence events. See [Models and the ORM](/v4-0-0-snapshot/basics/models-and-the-orm/).
+
+**Convention over configuration.** The philosophy that file names and class names imply table names, view paths, foreign keys, and routes without explicit registration. See [Conventions over Configuration](/v4-0-0-snapshot/core-concepts/conventions-over-configuration/).
+
+**`createTable`.** The migration method that returns a table-builder object; column methods chain off it until `.create()` flushes the `CREATE TABLE` to the database. See [Migrations](/v4-0-0-snapshot/basics/migrations/).
+
+## D
+
+**DataMapper.** The ORM pattern Wheels is *not* — plain data objects plus a separate repository that handles persistence. Contrasted with ActiveRecord. See [ORM Philosophy](/v4-0-0-snapshot/core-concepts/orm-philosophy/).
+
+**DI container.** The service container registered in `config/services.cfm` — you map names to components and resolve them by name anywhere in the app. See [The Dependency Injection Container](/v4-0-0-snapshot/core-concepts/dependency-injection/).
+
+**Dispatch.** The stage that picks a controller and action from `config/routes.cfm` and resolves route model bindings before filters run. See [The Request Lifecycle](/v4-0-0-snapshot/core-concepts/request-lifecycle/).
+
+**Dynamic scope.** A scope declared with `handler=` instead of a static `where=` string — the handler is a private method that returns query options per call, so it can read per-request state. See [Query Builder and Scopes](/v4-0-0-snapshot/basics/query-builder-and-scopes/).
+
+## E
+
+**Eager loading.** Joining associated tables into a finder's query with `include=` to avoid N+1 queries. See [Associations](/v4-0-0-snapshot/basics/associations/).
+
+**Enum.** A declaration that a property takes one of a fixed set of named values, auto-generating an inclusion validation, boolean checkers, and scopes. See [Query Builder and Scopes](/v4-0-0-snapshot/basics/query-builder-and-scopes/).
+
+**Environment.** One of `development`, `testing`, `production`, or `maintenance` — determined by `WHEELS_ENV` and controlling which `config//settings.cfm` overrides load. See [Environments and Configuration](/v4-0-0-snapshot/core-concepts/environments-and-configuration/).
+
+## F
+
+**Filter.** A private controller method registered in `config()` that runs around actions — `beforeAction` for auth and record loading, `afterAction` for logging. See [Controllers and Actions](/v4-0-0-snapshot/basics/controllers-and-actions/).
+
+**Finder.** A read method on a model — `findAll`, `findOne`, `findByKey`, `exists`, `count` — that queries the database and returns rows as query objects or model instances. See [Models and the ORM](/v4-0-0-snapshot/basics/models-and-the-orm/).
+
+**Flash.** A struct that survives exactly one redirect — write with `flashInsert` (or `redirectTo(..., success="...")`), read with `flash()` on the next request. See [Controllers and Actions](/v4-0-0-snapshot/basics/controllers-and-actions/).
+
+**Foreign key.** The column on the child table that points at the parent. Inferred as `Id` from the association name; override with `foreignKey=`. See [Associations](/v4-0-0-snapshot/basics/associations/).
+
+## H
+
+**`hasMany` / `belongsTo` / `hasOne`.** The three association declarations. Parent models name the child side with `hasMany` or `hasOne`; child models name the parent side with `belongsTo`. See [Associations](/v4-0-0-snapshot/basics/associations/).
+
+## I
+
+**Idempotent.** A job, seed, or handler that can run twice without changing the outcome — a hard requirement for `perform()` because every job will eventually run twice. See [Background Jobs](/v4-0-0-snapshot/digging-deeper/background-jobs/) and [Seeding](/v4-0-0-snapshot/basics/seeding/).
+
+**`inject()`.** The declarative injection call inside a controller's `config()` — the container resolves each named service once per instantiation and assigns it to `this.`. See [The Dependency Injection Container](/v4-0-0-snapshot/core-concepts/dependency-injection/).
+
+## J
+
+**Job.** A CFC in `app/jobs/` that extends `wheels.Job` and implements `perform(struct data)` — enqueued for asynchronous processing by the `wheels jobs work` worker. See [Background Jobs](/v4-0-0-snapshot/digging-deeper/background-jobs/).
+
+## L
+
+**Layout.** The outer template at `app/views/layout.cfm` that wraps every rendered view via `includeContent()`. Override per-action with `renderView(layout=...)` or per-controller with `usesLayout()`. See [Views, Layouts, Partials](/v4-0-0-snapshot/basics/views-layouts-partials/).
+
+## M
+
+**Mapper.** The fluent `mapper()` chain in `config/routes.cfm` that declares routes, resources, scopes, and the final `.wildcard()`. See [How Routing Works](/v4-0-0-snapshot/core-concepts/how-routing-works/).
+
+**Middleware.** A `(request, next)` function in the dispatch chain that runs before any controller is instantiated — the right home for CORS, rate limiting, security headers, tenant resolution, and auth gates. See [Middleware Pipeline](/v4-0-0-snapshot/core-concepts/middleware-pipeline/).
+
+**Migration.** A versioned CFC in `app/migrator/migrations/` with `up()` and `down()` methods that carry the schema forward and back. See [Migrations](/v4-0-0-snapshot/basics/migrations/).
+
+**Mixin target.** The `provides.mixins` value in a package manifest — `controller`, `view`, `model`, `global`, or `none` — controlling which framework components receive the package's public methods. See [Packages](/v4-0-0-snapshot/digging-deeper/packages/).
+
+**MVC.** Model-View-Controller — the layering rule Wheels applies the ActiveRecord way: fat models, thin controllers, dumb views. See [MVC in Wheels](/v4-0-0-snapshot/core-concepts/mvc-in-wheels/).
+
+## N
+
+**Named route.** A route declared with a `name` argument; helpers like `linkTo`, `redirectTo`, `urlFor`, `buttonTo`, and `startFormTag` look routes up by name rather than by URL string. See [How Routing Works](/v4-0-0-snapshot/core-concepts/how-routing-works/).
+
+**Nested resource.** A resource declared inside another via the `callback` form of `.resources(...)` so its URLs nest under the parent. See [How Routing Works](/v4-0-0-snapshot/core-concepts/how-routing-works/).
+
+## P
+
+**Package.** An optional first-party module in `packages/` that activates by being copied or symlinked into `vendor/`. `PackageLoader` discovers and loads each on startup inside its own try/catch. See [Packages](/v4-0-0-snapshot/digging-deeper/packages/).
+
+**`package.json`.** The manifest at the root of every package — `name`, `version`, `wheelsVersion`, `provides.mixins`, `provides.middleware`, `dependencies`. See [Packages](/v4-0-0-snapshot/digging-deeper/packages/).
+
+**Partial.** A view fragment whose filename starts with an underscore (`_form.cfm`), included via `includePartial(partial="form")` or rendered directly with `renderPartial`. See [Views, Layouts, Partials](/v4-0-0-snapshot/basics/views-layouts-partials/).
+
+**Polymorphic association.** An association where the same child model can belong to more than one parent type, distinguished by a `Type` string column alongside `Id`. See [Associations](/v4-0-0-snapshot/basics/associations/).
+
+**Primary key.** The column Wheels treats as the row's identity — `id` by convention; override with `setPrimaryKey()`. See [Models and the ORM](/v4-0-0-snapshot/basics/models-and-the-orm/).
+
+**Priority queue.** A named queue on `wheels_jobs.queue` that the worker drains ahead of others when run with `--queue=critical,default,low`. See [Background Jobs](/v4-0-0-snapshot/digging-deeper/background-jobs/).
+
+## Q
+
+**Query builder.** The chainable, auto-quoted fluent API — `.where().orderBy().limit().get()` — for composing queries without writing raw `WHERE` strings. See [Query Builder and Scopes](/v4-0-0-snapshot/basics/query-builder-and-scopes/).
+
+## R
+
+**Rate limiter.** The built-in `wheels.middleware.RateLimiter` that throttles requests using fixed-window, sliding-window, or token-bucket strategies. See [Middleware Pipeline](/v4-0-0-snapshot/core-concepts/middleware-pipeline/).
+
+**`references()`.** The migration shortcut that adds a `Id` integer column plus a foreign-key constraint to the parent's `id`. See [Migrations](/v4-0-0-snapshot/basics/migrations/).
+
+**Request lifecycle.** The eight-stage pipeline a request passes through — middleware, dispatch, controller instantiation, `beforeAction` filters, action, `afterAction` filters, view rendering, response. See [The Request Lifecycle](/v4-0-0-snapshot/core-concepts/request-lifecycle/).
+
+**Resource.** A `.resources("name")` call that expands into seven REST routes (index, new, create, show, edit, update, delete) plus their named helpers. See [How Routing Works](/v4-0-0-snapshot/core-concepts/how-routing-works/).
+
+**Route model binding.** The dispatch-stage behavior that loads `params.` from the database before the action runs — enabled per-resource with `binding=true` or globally with `set(routeModelBinding=true)`. See [Route Model Binding](/v4-0-0-snapshot/digging-deeper/route-model-binding/).
+
+**Row scoping.** The multi-tenancy strategy that stores all tenants' data in one schema and filters every query by a `tenantId` column (usually via a `currentTenant` scope). See [Multi-tenancy](/v4-0-0-snapshot/digging-deeper/multi-tenancy/).
+
+## S
+
+**Scope (query).** A named, reusable query fragment declared with `scope()` in `config()` and called as a chainable method on the model class — e.g. `model("User").active().recent()`. See [Query Builder and Scopes](/v4-0-0-snapshot/basics/query-builder-and-scopes/).
+
+**Scope (routing).** A `.scope(path="/api", ...)` block in `config/routes.cfm` that groups routes under a common path prefix, middleware set, or binding default. See [Middleware Pipeline](/v4-0-0-snapshot/core-concepts/middleware-pipeline/).
+
+**`seedOnce()`.** The idempotent seed function that looks up a record by `uniqueProperties` and creates it only if missing — safe to re-run any time. See [Seeding](/v4-0-0-snapshot/basics/seeding/).
+
+**`set()`.** The sole API for writing framework settings — `set(key=value)` in `config/settings.cfm` or a per-environment override file. See [Environments and Configuration](/v4-0-0-snapshot/core-concepts/environments-and-configuration/).
+
+**`sharedModel()`.** A `config()` declaration that opts a model out of tenant datasource routing — used on global tables like `Tenant`, `Plan`, or feature flags. See [Multi-tenancy](/v4-0-0-snapshot/digging-deeper/multi-tenancy/).
+
+**Singleton.** A DI scope — `.asSingleton()` — that registers one instance for the life of the application. See [The Dependency Injection Container](/v4-0-0-snapshot/core-concepts/dependency-injection/).
+
+## T
+
+**`tenant()`.** The helper that returns the current tenant struct (or `{}` if none is active) anywhere framework functions are callable. See [Multi-tenancy](/v4-0-0-snapshot/digging-deeper/multi-tenancy/).
+
+**`TenantResolver`.** The middleware at `wheels.middleware.TenantResolver` that populates `request.wheels.tenant` per request and unlocks per-request datasource switching. See [Multi-tenancy](/v4-0-0-snapshot/digging-deeper/multi-tenancy/).
+
+**`timestamps()`.** The migration column helper that adds `createdAt`, `updatedAt`, **and** a soft-delete `deletedAt` column in one call. See [Migrations](/v4-0-0-snapshot/basics/migrations/).
+
+**Transient.** The default DI scope — a new instance is returned every time the name is resolved. See [The Dependency Injection Container](/v4-0-0-snapshot/core-concepts/dependency-injection/).
+
+## V
+
+**Validation.** A declaration in `config()` (`validatesPresenceOf`, `validatesUniquenessOf`, `validatesFormatOf`, and the rest) that runs on every `save()` and blocks the write when any rule fails. See [Models and the ORM](/v4-0-0-snapshot/basics/models-and-the-orm/).
+
+## W
+
+**`wheels` CLI.** The command-line surface for generators, migrations, seeds, jobs, and the test runner — `wheels generate`, `wheels migrate`, `wheels seed`, `wheels jobs work`, `wheels test run`. See [Migrations](/v4-0-0-snapshot/basics/migrations/) and [Background Jobs](/v4-0-0-snapshot/digging-deeper/background-jobs/).
+
+**Wildcard route.** The `.wildcard()` call that must come last in `config/routes.cfm` — it catches anything that fell through and maps `/controller/action` conventionally. See [How Routing Works](/v4-0-0-snapshot/core-concepts/how-routing-works/).
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/2x-to-3x.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/2x-to-3x.mdx
new file mode 100644
index 0000000000..ebe638dc23
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/2x-to-3x.mdx
@@ -0,0 +1,36 @@
+---
+title: Upgrading from 2.x to 3.x
+description: Pointer to the frozen v3.0 guides, which remain the authoritative source for the 2.x to 3.x hop.
+type: howto
+sidebar:
+ order: 3
+---
+
+import { Aside, LinkCard } from '@astrojs/starlight/components';
+
+Wheels 3.0 is documented in its own frozen guide. If you are upgrading 2.x to 3.x, use that as your authoritative source — it covers the config directory move, renamed conventions, and every other breaking change in detail.
+
+
+
+
+
+## If you are hopping 2.x to 4.x in one sitting
+
+Do it in two passes, not one:
+
+1. Upgrade 2.x to 3.x first, following the [v3.0 guides](/v3-0-0/). Get the app booting on 3.x before touching 4.x.
+2. Then read [Upgrading from 3.x to 4.x](/v4-0-0-snapshot/upgrading/3x-to-4x/) and apply those changes.
+
+Skipping the 3.x landing creates a combinatorial upgrade. Many 2→3 breaking changes assume 3.x semantics once applied, and many 3→4 breaking changes assume the same. Debugging a failure that straddles both hops is painful. Landing on 3.x first gives you a known-good checkpoint.
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/3x-to-4x.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/3x-to-4x.mdx
new file mode 100644
index 0000000000..8854dff893
--- /dev/null
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/3x-to-4x.mdx
@@ -0,0 +1,332 @@
+---
+title: Upgrading from 3.x to 4.0
+description: The breaking-change map for existing Wheels 3.x apps — what changed, how to detect it, and how to fix it.
+type: howto
+sidebar:
+ order: 2
+---
+
+import { Aside, CardGrid, LinkCard, Steps } from '@astrojs/starlight/components';
+
+This guide walks every breaking change between Wheels 3.x and 4.0, with before/after code for each. Most 3.x apps upgrade in an afternoon. The pressure points are CORS defaults, the test base class rename, the `plugins/` → `packages/` split, and the `wheels` CLI command renames. Everything else is either additive or continues to work with a deprecation warning.
+
+**You'll learn:**
+
+- Every breaking default and rename in 4.0, with the source PR or `CHANGELOG.md` entry
+- How to port a 3.x plugin to the new `packages/` activation model
+- How to move your tests from `wheels.Test` (RocketUnit) to `wheels.WheelsTest` (BDD)
+- What to verify after the upgrade and where to look when something misbehaves
+
+
+
+## Pre-upgrade checklist
+
+
+
+1. Pin your CFML engine versions in `.cfconfig.json` or your container image. 4.0 supports Lucee 5/6/7, Adobe ColdFusion 2018/2021/2023/2025, and BoxLang — the reference platform is Lucee 7 + SQLite.
+
+2. Take a database snapshot. The migrator's `migrateTo()` behavior was tightened in 4.0 to detect previously-skipped migrations in a range (`CHANGELOG.md` → Fixed, #1928).
+
+3. Confirm your 3.x test suite is green. If it isn't, upgrading will mix real breakage with pre-existing failures.
+
+4. Read the audit and comparison docs for context: [`docs/releases/wheels-3.0-vs-4.0.md`](https://github.com/wheels-dev/wheels/blob/develop/docs/releases/wheels-3.0-vs-4.0.md) and [`docs/releases/wheels-4.0-audit.md`](https://github.com/wheels-dev/wheels/blob/develop/docs/releases/wheels-4.0-audit.md).
+
+5. Replace `vendor/wheels/` with the 4.0 source. If you vendor the framework, `git checkout` the 4.0 tag. If you clone-and-run, pull.
+
+
+
+## The ten breaking changes
+
+Each item cites its `CHANGELOG.md` "Changed" or "Removed" entry. Where the skeleton blog post calls out a two-path upgrade, Path A (fix directly) is documented here; Path B (the Legacy Compatibility Adapter, #2015) is covered at the end.
+
+### 1. CORS default changed from wildcard to deny-all
+
+**CHANGELOG:** `Breaking: CORS middleware default changed from wildcard * to deny-all` (#2039).
+
+In 3.x, the CORS middleware defaulted to `allowOrigins="*"`. In 4.0, there is no default — you must configure `allowOrigins` explicitly or requests are rejected.
+
+```cfm {test:compile} title="config/settings.cfm"
+// 3.x — no configuration needed; wildcard was the default
+set(middleware = [ new wheels.middleware.Cors() ]);
+```
+
+```cfm {test:compile} title="config/settings.cfm"
+// 4.0 — allowOrigins is required
+set(middleware = [
+ new wheels.middleware.Cors(allowOrigins="https://myapp.com,https://admin.myapp.com")
+]);
+```
+
+Related hardening: wildcard-plus-credentials combinations are now rejected (#2053).
+
+### 2. HSTS defaults on in production
+
+**CHANGELOG:** `Breaking: HSTS header defaults on in production` (#2081).
+
+The `SecurityHeaders` middleware (#2036) emits `Strict-Transport-Security: max-age=31536000; includeSubDomains` by default in the `production` environment. If you terminate TLS outside the app and do not want HSTS emitted from here, there is currently no clean off-switch — see [#2174](https://github.com/wheels-dev/wheels/issues/2174). The workaround until that lands is to strip `Strict-Transport-Security` at your reverse proxy. `deployment/security-hardening.mdx` covers the configuration knobs that do exist today.
+
+### 3. CSRF key required in production; JWT algorithm validated
+
+**CHANGELOG:** `Breaking: Reload password must be non-empty for environment switching in production` (#2082) and the Security section item for `CSRF key enforced in production` (#2079).
+
+The CSRF encryption key is auto-generated if empty (#2054), but cookies rotate on every deploy when that happens. Set a stable key.
+
+```cfm {test:compile} title="config/settings.cfm"
+set(csrfEncryptionKey = env("WHEELS_CSRF_KEY"));
+```
+
+JWT verification now validates the `alg` claim and uses constant-time signature comparison (#2079, #2086). Tokens forged with `alg: none` or mismatched algorithms are rejected.
+
+### 4. `allowEnvironmentSwitchViaUrl` defaults to false in production
+
+**CHANGELOG:** `Breaking: allowEnvironmentSwitchViaUrl defaults to false in production` (#2076) and the reload-password requirement (#2082).
+
+In 3.x, `?environment=production` on a URL could switch the running environment. In 4.0, that is off by default in production and the reload password must be non-empty. Re-enable only for controlled staging environments.
+
+### 5. RateLimiter defaults hardened
+
+**CHANGELOG:** `Breaking: RateLimiter trustProxy default changed from true to false` (#2024); `Breaking: RateLimiter proxy strategy default changed to last` (#2088).
+
+If your app sits behind a proxy or load balancer, configure both flags explicitly — do not rely on the 3.x dev defaults.
+
+```cfm {test:compile} title="config/settings.cfm"
+set(middleware = [
+ new wheels.middleware.RateLimiter(
+ maxRequests = 100,
+ windowSeconds = 60,
+ trustProxy = true,
+ proxyStrategy = "last"
+ )
+]);
+```
+
+Rate limiter also now fails closed on lock timeout (#2069) rather than fail-open.
+
+### 6. CSRF cookie sets `SameSite`
+
+**CHANGELOG:** `Breaking: CSRF cookie now sets SameSite attribute` (#2035).
+
+Cross-site `POST`s from third-party frames that relied on the missing attribute will break. Same-site app flows are unaffected.
+
+### 7. `wheels snippets` renamed to `wheels code`
+
+**CHANGELOG:** `Breaking: wheels snippets CLI command renamed to wheels code` (#1852).
+
+Update any scripts, CI jobs, or IDE integrations that shell out to `wheels snippets`. The flag surface is otherwise unchanged.
+
+### 8. Test base class renamed: `wheels.Test` → `wheels.WheelsTest`
+
+**CHANGELOG:** `Breaking: Test base class namespace renamed` (#1889) and `Deprecated: wheels.Test test base class` (#1889).
+
+The RocketUnit-era base (`wheels.Test`) still loads during 4.0, but all new tests extend `wheels.WheelsTest` and use BDD syntax.
+
+```cfm {test:compile} title="tests/specs/models/UserSpec.cfc — 3.x style (still loads, legacy only)"
+component extends="wheels.Test" {
+ function test_user_requires_email() {
+ var u = model("User").new();
+ assert("NOT u.valid()");
+ }
+}
+```
+
+```cfm {test:compile} title="tests/specs/models/UserSpec.cfc — 4.0 BDD style"
+component extends="wheels.WheelsTest" {
+ function run() {
+ describe("User", () => {
+ it("requires an email", () => {
+ var u = model("User").new();
+ expect(u.valid()).toBeFalse();
+ });
+ });
+ }
+}
+```
+
+See the [Testing guide](/v4-0-0-snapshot/testing/) for matchers and the phantom-matcher list.
+
+### 9. Tests directory renamed: `tests/specs/functions/` → `tests/specs/functional/`
+
+**CHANGELOG:** `Breaking: Tests directory tests/specs/functions/ renamed to tests/specs/functional/` (#1872).
+
+Rename the directory. No code changes required.
+
+### 10. `application.wirebox` renamed to `application.wheelsdi`
+
+**CHANGELOG:** `Breaking: application.wirebox renamed to application.wheelsdi` (#1888).
+
+The DI container moved in-house (`CHANGELOG.md` → `Internal rim modernized: WireBox/TestBox replaced`, #1883). The surface is compatible, but code that reached into `application.wirebox` directly must be updated.
+
+```cfm {test:compile} title="app/lib/Something.cfc"
+// 3.x
+var svc = application.wirebox.getInstance("emailService");
+// 4.0
+var svc = application.wheelsdi.getInstance("emailService");
+// 4.0, preferred
+var svc = service("emailService");
+```
+
+`service()` is the new global helper (#1933); prefer it over reaching into the container.
+
+## Deprecations you should plan to address
+
+### Plugins → packages
+
+**CHANGELOG:** `Deprecated: Legacy plugins/ folder — superseded by the new packages/ → vendor/ activation model. Plugins still load, with a deprecation warning.` (#1995).
+
+The `plugins/` directory still works. It emits a warning at load. Plan a migration — here's the shape of porting a 3.x plugin called `AuditLog`:
+
+
+
+1. Create a package directory. The main CFC's filename matches the directory name.
+
+ ```
+ packages/auditlog/
+ package.json
+ AuditLog.cfc
+ ```
+
+2. Write the `package.json` manifest. `provides.mixins` is opt-in; 3.x plugins defaulted to `global`, which is why provenance was hard to trace.
+
+ ```json title="packages/auditlog/package.json"
+ {
+ "name": "audit-log",
+ "version": "1.0.0",
+ "description": "Writes a row to audit_log for every mutating action",
+ "wheelsVersion": ">=4.0",
+ "provides": {
+ "mixins": "controller"
+ }
+ }
+ ```
+
+3. Port the plugin CFC. Rename lifecycle hooks if you used them — `init`, `register`, `boot`, `onPluginLoad`, `onPluginActivate` are reserved and never mixed in.
+
+ ```cfm {test:compile} title="packages/auditlog/AuditLog.cfc"
+ component output="false" {
+
+ public any function init() {
+ return this;
+ }
+
+ public void function recordAudit(required string action, required numeric recordId) {
+ model("AuditEntry").create(
+ action = arguments.action,
+ recordId = arguments.recordId,
+ userId = session.userId ?: 0
+ );
+ }
+ }
+ ```
+
+4. Activate by copying (or symlinking) into `vendor/`, then reload.
+
+ ```bash title="your shell"
+ cp -r packages/auditlog vendor/auditlog
+ wheels reload
+ ```
+
+5. Delete `plugins/auditlog/`. The deprecation warning goes away.
+
+
+
+See [Packages](/v4-0-0-snapshot/digging-deeper/packages/) for manifest fields, per-method mixin overrides via `mixin="view"` annotations, and error isolation. If you need a staged migration, the `legacyadapter` package (`packages/legacyadapter/`) ships deprecation logging and API shims for 3.x patterns.
+
+### Monolithic `paginationLinks()`
+
+**CHANGELOG:** composable pagination helpers added (#1930). The 3.x `paginationLinks()` is retained. New code should use `paginationNav()` or compose the individual helpers (`paginationInfo`, `firstPageLink`, `previousPageLink`, `pageNumberLinks`, `nextPageLink`, `lastPageLink`).
+
+### RocketUnit test style
+
+**CHANGELOG:** `Deprecated: RocketUnit test style for new tests` (#1925) and `Removed: Legacy RocketUnit core test scaffolding` (#1925). Existing app specs still run; the framework-level RocketUnit runner is gone. Do not write new tests in RocketUnit style.
+
+### In-dev-server HTTP MCP endpoint
+
+**CHANGELOG:** `Deprecated: In-dev-server HTTP MCP endpoint at /wheels/mcp — superseded by the LuCLI stdio MCP server (wheels mcp wheels).` Migrate with `wheels mcp setup --force`.
+
+## Removed in 4.0
+
+**CHANGELOG** → Removed:
+
+- **Legacy RocketUnit core test scaffolding** (#1925) — framework-level runner only; app specs still load.
+- **Railo compatibility workaround** in `$initializeMixins` (#1987) — Railo is no longer a target.
+- **`server.cfc`** file (#1902).
+- **`cli/lucli/services/MCP.cfc`** parallel schema registry — never wired into LuCLI's MCP discovery; rich parameter schemas return via typed parameters on `Module.cfc` in a follow-up.
+
+## The Legacy Compatibility Adapter (optional soft landing)
+
+**CHANGELOG:** `Legacy compatibility adapter for 3.x → 4.0 migration soft-landing` (#2015), shipped as `packages/legacyadapter/`.
+
+When you can't fix all ten breaking items in one sprint, activate the adapter, ship the upgrade, and modernize incrementally:
+
+```bash title="your shell"
+cp -r packages/legacyadapter vendor/legacyadapter
+wheels reload
+```
+
+The adapter logs every deprecated call so you can track what's still 3.x-shaped in production.
+
+## Recommended, not required
+
+These are additive in 4.0 and worth adopting during the upgrade window:
+
+- **Middleware pipeline** (#1924) for cross-cutting concerns previously done with filters or plugins.
+- **Route model binding** (#1929) — `binding=true` on resource routes auto-resolves `params.user`, cutting boilerplate `findByKey()` in every `show`/`edit`/`update`.
+- **Chainable QueryBuilder** (#1922) everywhere you concatenated `WHERE` strings.
+- **Built-in job worker** (#1934) to replace external Redis-backed queues.
+- **HTTP test client** (`TestClient`, #2099) for integration tests.
+- **Parallel test runner** (#2100) for faster CI.
+
+## Post-upgrade verification
+
+
+
+1. Run the test suite.
+
+ ```bash title="your shell"
+ bash tools/test-local.sh
+ ```
+
+2. Run `wheels doctor` to surface config and environment issues.
+
+ ```bash title="your shell"
+ wheels doctor
+ ```
+
+3. Smoke-test a form submission end-to-end. CSRF and `SameSite` cookie tightening can break third-party embeds silently.
+
+4. Check the application log on first request after deploy. `PackageLoader` reports every package it loaded, every package it skipped, and why. If you ported a plugin, confirm the old entry is gone and the new one loaded.
+
+5. If you configured HSTS or CORS in code, verify the response headers with `curl -I`. Misconfigured `allowOrigins` returns no CORS header at all — the browser reports it as a generic CORS failure.
+
+
+
+## Common issues
+
+- **"CSRF token invalid" on forms after upgrade.** Either the encryption key rotated (set `csrfEncryptionKey` explicitly) or a third-party embed is hitting `SameSite`. See #2035, #2054.
+- **Requests return 403 with no CORS header.** `allowOrigins` isn't set. See #2039.
+- **Rate limiter counts wrong behind your load balancer.** `trustProxy` defaults to `false` now. See #2024, #2088.
+- **Test runner can't find specs.** Check for `tests/specs/functions/` — rename to `functional/`. See #1872.
+- **Plugin warning at startup.** Port to `packages/` (see above) or accept the warning until you do.
+- **JWT tokens reject as invalid after upgrade.** Algorithm validation is on. Confirm your issuer sets `alg` to a supported value and the signature verification path uses constant-time comparison. See #2079, #2086.
+
+## Related guides
+
+
+
+
+
+
diff --git a/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/index.mdx b/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/index.mdx
index 6391482257..affe115041 100644
--- a/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/index.mdx
+++ b/web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/index.mdx
@@ -1,9 +1,65 @@
---
-title: Upgrading
-description: Upgrade guides for each Wheels release.
+title: Upgrading Wheels
+description: Versioning policy, release cadence, and how to move your app across major versions of the framework.
type: section
sidebar:
order: 9
---
-Placeholder — content lands in Phase 2.
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+Upgrade an existing app to a new version of Wheels. Start with the policy, then follow the version-specific guide for the jump you're making.
+
+## Versioning policy
+
+Wheels follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). The `CHANGELOG.md` at the repo root declares this explicitly and groups every release under `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security` headings in the Keep a Changelog format.
+
+- **Major** (`3.x` → `4.x`) — breaking changes are allowed. Read the upgrade guide for that jump before you touch code.
+- **Minor** (`4.0` → `4.1`) — additive only. New features, no breaking changes to supported APIs.
+- **Patch** (`4.0.0` → `4.0.1`) — bug fixes and security patches. Safe to apply without reading release notes, though you still should.
+
+Deprecations land in a minor release with a log warning and stay functional until the next major. Check the `Deprecated` section of every release you pass through.
+
+## Where to find the current version
+
+Three sources, in order of authority:
+
+- `vendor/wheels/events/onapplicationstart.cfc` — the `application.$wheels.version` assignment is the runtime truth. As of this snapshot, `4.0.0`.
+- `box.json` at the repo root — the package manifest version.
+- `CHANGELOG.md` — the most recent non-`[Unreleased]` heading.
+
+If any two disagree, the runtime constant wins — that's the version your app actually boots against.
+
+## Release cadence
+
+Wheels doesn't ship on a fixed schedule. Releases go out when the changelog entries warrant one and the test matrix is green. The git tag history is the authoritative record — `git tag --sort=-v:refname` shows the latest builds (currently `v4.0.0-SNAPSHOT+1524` on the way to the `4.0.0` GA tag). The `-SNAPSHOT+N` suffix is a continuous build number, not a release.
+
+Don't assume a release date. Watch the [releases page on GitHub](https://github.com/wheels-dev/wheels/releases) and subscribe to the repo if you need to know when a tagged build lands.
+
+## Upgrade philosophy
+
+Four rules, in order:
+
+1. **Read the version-specific guide first.** The pages below enumerate every breaking change and the migration path for each one. Skimming is a false economy.
+2. **One major at a time.** Going from 2.x to 4.x means doing the 2.x→3.x upgrade, verifying your app boots and tests pass, then doing 3.x→4.x. The guides assume you're coming from the previous major, not two back.
+3. **Upgrade the framework separately from the engine.** Wheels runs on Lucee 5/6/7, Adobe ColdFusion 2018/2021/2023/2025, and BoxLang. Change one variable at a time — framework version, then engine version, not both together. When something breaks you'll know which change caused it.
+4. **Run your full test suite against the new version before deploying.** If you don't have one, the upgrade is also the moment to start — even a handful of smoke tests will catch 90% of regressions.
+
+
+
+## Upgrade guides
+
+
+
+
+
diff --git a/web/sites/guides/src/sidebars/v4-0-0-snapshot.json b/web/sites/guides/src/sidebars/v4-0-0-snapshot.json
index 7ab647c79c..1f579206da 100644
--- a/web/sites/guides/src/sidebars/v4-0-0-snapshot.json
+++ b/web/sites/guides/src/sidebars/v4-0-0-snapshot.json
@@ -92,7 +92,13 @@
{
"label": "Deployment & Operations",
"link": "/v4-0-0-snapshot/deployment/",
- "items": []
+ "items": [
+ { "label": "Production Configuration", "link": "/v4-0-0-snapshot/deployment/production-config/" },
+ { "label": "Docker Deployment", "link": "/v4-0-0-snapshot/deployment/docker-deployment/" },
+ { "label": "VM and Bare-metal Deployment", "link": "/v4-0-0-snapshot/deployment/vm-deployment/" },
+ { "label": "Security Hardening", "link": "/v4-0-0-snapshot/deployment/security-hardening/" },
+ { "label": "Observability and Logging", "link": "/v4-0-0-snapshot/deployment/observability-and-logging/" }
+ ]
},
{
"label": "CLI Reference",
@@ -132,12 +138,19 @@
{
"label": "Contributing & Project",
"link": "/v4-0-0-snapshot/contributing/",
- "items": []
+ "items": [
+ { "label": "Submitting Pull Requests", "link": "/v4-0-0-snapshot/contributing/pull-requests/" },
+ { "label": "Coding Standards", "link": "/v4-0-0-snapshot/contributing/coding-standards/" },
+ { "label": "Writing Documentation", "link": "/v4-0-0-snapshot/contributing/writing-docs/" }
+ ]
},
{
"label": "Upgrading",
"link": "/v4-0-0-snapshot/upgrading/",
- "items": []
+ "items": [
+ { "label": "Upgrading from 3.x to 4.0", "link": "/v4-0-0-snapshot/upgrading/3x-to-4x/" },
+ { "label": "Upgrading from 2.x to 3.x", "link": "/v4-0-0-snapshot/upgrading/2x-to-3x/" }
+ ]
},
{
"label": "Glossary",