Repository navigation
Description
Problem
The wave-2 framework review's feature-set assessment (FRAMEWORK-REVIEW-2026-06-09.md §5.2) identifies four High-impact application-layer parity gaps. Grouped into one roadmap issue to bound issue count; each section is an independent, separately-shippable feature. The framework already ships the lower-level primitives for several of these — the gap is the developer-facing scaffold/abstraction on top.
1. Authentication scaffold/generator (High / Medium effort)
No wheels generate auth producing a User model + secure password + registration/login/reset/verification flows on top of the existing vendor/wheels/auth/ strategies (Authenticator + Session/Token/JWT + JwtService). The primitives exist; the one-command scaffold does not.
Prior art: Rails 8 generate authentication; Laravel Fortify.
2. Authorization / policy layer (High / Medium effort)
No gates/policies/can() abstraction. AuthMiddleware answers "who are you" but not "may you do this". Apps must hand-roll authorization in controller filters.
Prior art: Laravel Gates + Policies; Pundit.
3. File attachments / Active Storage equivalent (High / High effort)
No hasOneAttached/hasManyAttached, no storage-service abstraction (local/S3/etc.), no variants, no signed URLs. Uploads are raw cffile.
Prior art: Rails Active Storage.
4. Channels documentation + WebSocket parity (High docs / Medium WS, Medium-High effort)
Working SSE channels ship with zero user-facing docs (.ai/wheels/channels/channels.md is internal only). SSE is server→client only; there is no client→server WebSocket path.
Prior art: Rails ActionCable; Phoenix Channels; Laravel Reverb.
Impact
These are the most-requested parity gaps versus Rails 8 / Laravel 12 for teams adopting Wheels for new apps. Auth scaffold and authorization in particular are table-stakes for typical CRUD apps; the absence forces every app to hand-roll them. Channels has working code with no docs — the lowest-effort, highest-leverage of the four (docs first, WebSocket later).
Suggested approach
Treat each section as its own design + implementation effort, sequenced by effort/leverage:
- Channels docs first (code exists; write user-facing guide pages under
web/sites/guides/), then scope client→server WebSocket support. - Authentication generator on top of
vendor/wheels/auth/—wheels generate authproducing model + migrations + controllers + views, callingsuper.config()(see the CSRF footgun, this campaign). - Authorization policy layer — gates/policies/
can(), composing withAuthMiddleware. - File attachments — storage-service abstraction with local + S3 adapters, model macros, signed URLs, variants.
Each warrants its own design discussion; this issue is the roadmap anchor. Split into per-feature issues once each is scheduled.
Acceptance criteria
- Channels has user-facing guide docs (docs(web/guides): add Channels pub/sub guide with honest WebSocket status #3004); WebSocket client→server support shipped as the opt-in
wheels-websocketspackage (package: wheels-websockets — opt-in realtime WS transport (RustCFML native, Lucee emulated) #3292 / channels: WebSocket parity — demand probe before any design work #3154). -
wheels generate authscaffolds a working auth flow on the existing strategies (feat(auth): PBKDF2 password hashing service (PasswordHasher) #3288 + feat(cli): wheels generate auth — session/token/jwt scaffold on the auth primitives #3291). - An authorization/policy abstraction (
can()+ policy objects) exists and composes with middleware (feat(controller): authorization policy layer — wheels.Policy + authorize()/can()/policyScope() #3289). - A file-attachments abstraction with a pluggable storage service exists — phase 1 (disk abstraction) shipped (feat(storage): add storage-disk abstraction with local and S3 drivers (#3157) #3248 / feature: storage-disk abstraction (local + S3 adapters, signed URLs) — attachments phase 1 #3157); model macros/variants/signed URLs are the remaining phase-2 slice.
Source
Internal multi-agent framework review 2026-06-09, wave 2 (issues phase). Feature-set assessment §5.2, High-impact gaps: Authentication scaffold/generator, Authorization/policy layer, File attachments/Active Storage, Channels docs + WebSocket parity.
Tracking checklist (converted 2026-06-12 per the sequencing comment above)
- Channels documentation slice — shipped in docs(web/guides): add Channels pub/sub guide with honest WebSocket status #3004
- channels: WebSocket parity — demand probe before any design work #3154 — WebSocket parity — decided 2026-07-06: GO as opt-in package (RustCFML native, Lucee emulated); implementation tracked in package: wheels-websockets — opt-in realtime WS transport (RustCFML native, Lucee emulated) #3292, probe Do you need WebSockets in Wheels? Real-world demand check for channels #3286 now prioritizes engines/features
- feature: wheels generate auth — session auth scaffold on the existing wheels.auth primitives #3155 —
wheels generate auth— shipped in feat(auth): PBKDF2 password hashing service (PasswordHasher) #3288 (PasswordHasher) + feat(cli): wheels generate auth — session/token/jwt scaffold on the auth primitives #3291 (generator), merged 2026-07-06 - feature: authorization/policy layer (wheels.Policy + authorize()/can()/policyScope()) #3156 — authorization/policy layer — shipped in feat(controller): authorization policy layer — wheels.Policy + authorize()/can()/policyScope() #3289, merged 2026-07-06
- feature: storage-disk abstraction (local + S3 adapters, signed URLs) — attachments phase 1 #3157 — storage-disk abstraction, attachments phase 1 — shipped in feat(storage): add storage-disk abstraction with local and S3 drivers (#3157) #3248, closed 2026-06-24
Activity
wheels-bot commentedon Jun 10, 2026
Wheels Bot — Triage
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
<!-- wheels-bot:triage:2962 -->
<!-- wheels-bot:triage-class:framework-design -->
wheels-bot commentedon Jun 10, 2026
Wheels Bot — Cross-Framework Research (1/2)
Question. Issue 2962 is a roadmap bundling four independent High-impact application-layer parity gaps: (1)
wheels generate authscaffold onvendor/wheels/auth/; (2) authorization / policy layer; (3) file attachments + storage-service abstraction; (4) Channels docs + WebSocket parity. The issue itself says "Split into per-feature issues once each is scheduled." Six parallel framework agents covered Rails 8, Laravel 12, Django 5.x, Phoenix 1.7, Spring Boot 3.x, and AdonisJS 6 as the +1. AdonisJS is the most relevant peer — it is the only other batteries-included MVC framework that ships first-party packages for all four features simultaneously, and it independently chose SSE-only (Transmit) over WebSocket, mirroring Wheels current design.Topic 1 — Authentication Generator
bin/rails generate authentication— User + Session models, SessionsController, PasswordsController,Authenticationconcern,Current.user. Zero gem deps, DB-revocable sessions; no registration/2FA/OAuth.php artisan fortify:install). Headless routes; closures-as-views; 2FA/email-verify/password-confirm. Own everything generated; no upgrade path.django.contrib.authbuilt-in app +createsuperuser. Library, not generator;LoginView/PasswordResetView; templates are stubs.mix phx.gen.auth Accounts User users— context + User + UserToken + LiveViews + Notifier + UserAuth plug. Widely cited gold-standard.SecurityFilterChain+UserDetailsService+PasswordEncoderbeans. Auto-config, no scaffold tradition.node ace add @adonisjs/auth --guard=session— configure-as-scaffold.withAuthFindermixin;auth.use("web").login(user).Dominant pattern. Modern frameworks ship a generator that emits real app code (Rails 8, Phoenix, Laravel starter kits, Adonis
add). Django and Spring Boot are library-only. The shape is universal: model + sessions table + sessions controller + password-reset controller + plug/concern/middleware exposingcurrent_user. Wheels already has the primitives (Authenticator,SessionStrategy,JwtService,AuthMiddleware) — the gap is the generator on top.Topic 2 — Authorization / Policy Layer
authorize @post,policy_scope(Post). Plain Ruby; per-resource policy classes.Gate::define,$user->can(...),@can, auto-discovery,[UsePolicy]attribute.before()returningtrueis allow-all gotcha.user.has_perm("app.codename")— table-level only. guardian for object-level; rules for predicates.phx.gen.authScope (1.8) formalizes data-boundary.@PreAuthorize+ SpEL. ACL module for object-level. SpEL is stringly-typed.@adonisjs/bouncerfirst-party. Abilities (Gate-style fns) + Policy classes;ctx.bouncer.with(PostPolicy).authorize("edit", post).Dominant pattern. Per-resource policy classes with method-per-action (
update/delete/create) plus a thin gate primitive for non-model checks. Laravel and Adonis ship first-party; Rails uses Pundit by convention; Phoenix uses Bodyguard or data-boundary. Django table-level permissions are weaker than the rest. Thecan()+authorize()pair is universal. WheelsAuthMiddlewareanswers "who you are"; this layer answers "may you do this."Topic 3 — File Attachments / Storage
has_one_attached :avatar. Polymorphic blob/attachment split; variants; signed URLs (5min TTL); direct uploads. Only framework with first-party model macro.Storagefacade (Flysystem). Disks (local/public/s3/sftp);Storage::disk("s3")->temporaryUrl(). Model attachment via community spatie/medialibrary.FileField/ImageField+STORAGESsetting (4.2+).upload_to; per-fieldstorage=callable. django-storages for S3.allow_upload/3LiveView for transport only. Storage gap filled by Waffle.MultipartFile+@RequestPart. Cloud via Spring Cloud AWS S3Template. No model abstraction.@adonisjs/drivestorage. Disks (fs/s3/gcs/r2/spaces/supabase);file.moveToDisk(key, "s3"); signed URLs. Community adonisjs-attachment for model-bound.Dominant pattern. Splits into two layers: (a) storage-service / disk abstraction (
local/s3/gcs/azure) — every framework ships this; (b) model-attachment macro (has_one_attached) — only Rails ships first-party. Storage abstraction is lower-effort, higher-leverage — ship that first, evaluate the macro later.Topic 4 — Channels / WebSocket
stream_for @post;Channel.broadcast_to(...);@rails/actioncableclient. Postgres adapter caps at ~8KB; Redis needed.install:broadcasting;Broadcast::channel(...); Echo client. Pusher wire-compatible.WebsocketConsumer;channel_layersRedis;group_send. Sync ORM needsdatabase_sync_to_async().join/3auth;Phoenix.PresenceCRDT. Gold-standard; PG2 clusters with zero infra.@MessageMapping;SimpMessagingTemplate; simple broker or RabbitMQ relay.transmit.broadcast(channel, payload);transmit.authorize<{id}>(...). Proxy-friendly, auto-reconnect, reuses HTTP auth; no client→server, no binary.Dominant pattern. Five of six frameworks ship WebSocket-based bidirectional channels with per-channel auth callbacks, broadcast helpers, and a JS client with auto-reconnect. Adonis is the outlier — and it is the most directly relevant prior art for Wheels, which already chose SSE in
vendor/wheels/channels/. The Adonis team documented rationale (firewall/proxy traversal, free reconnect, reuse of HTTP auth) maps 1:1 onto why SSE is a defensible Wheels choice. The +1-framework divergence is non-trivial: WebSocket parity is real demand but ships a second protocol stack. Documenting the SSE choice + scoping a complementary WebSocket may be more honest than positioning WebSocket as a gap.(continued in part 2 — recommended path, CFML sketches, cross-engine notes, confidence, sources, marker)
wheels-bot commentedon Jun 10, 2026
Wheels Bot — Cross-Framework Research (2/2)
Recommended path for Wheels
Treat this issue as a roadmap and split it. The sequencing in the issue body is sound; here are the per-feature shapes for the resulting child issues.
1. Channels docs — lowest effort, highest leverage; code already ships. Promote
.ai/wheels/channels/channels.mdintoweb/sites/guides/src/content/docs/v4-0-0/with user-facing examples, deployment notes (memory vs database adapter, proxy-must-disable-compression-for-text/event-stream), and an explicit position on SSE vs WebSocket citing Adonis Transmit as prior art. Then the WebSocket question becomes "do we need bidirectional binary? if yes, scope awheels.channels.WebSocketAdapterthat composes with the existingchannelAdaptersetting."2.
wheels generate authon existing primitives. Phoenix-style scaffold layered onvendor/wheels/auth/:wheels generate auth // User model + migrations + controllers + views + routes wheels generate auth --model=Account // override model name wheels generate auth --strategy=jwt // pick strategy from vendor/wheels/auth/ // Generated: // app/models/User.cfc extends "Model" — hasSecurePassword(), validations // db/migrate/*_create_users.cfc users + sessions tables // app/controllers/Sessions.cfc login/logout // app/controllers/Passwords.cfc reset request + edit // app/controllers/Registrations.cfc (--no-registration to skip) // app/views/sessions/{new,create}.cfm // config/routes.cfm resource("session"), resources("passwords") // config/settings.cfm set(authStrategy="session")Lives in:
cli/lucli/services/Scaffold.cfc+cli/lucli/templates/auth/. Composes with existingvendor/wheels/auth/Authenticator.cfcstrategies,AuthMiddleware, and DI container (service("authenticator")). All-named args; no mixin gotchas because generated files are user-owned.3. Authorization / policy layer — Laravel + Pundit hybrid. First-party policy objects + a gate primitive:
// app/policies/PostPolicy.cfc component extends="wheels.Policy" { public boolean function update(required user, required post) { return arguments.user.isAdmin() || arguments.post.userId == arguments.user.id; } public boolean function destroy(required user, required post) { return arguments.user.isAdmin(); } } // Controller authorize(action="update", record=post); // throws Wheels.NotAuthorized -> 403 if (can(action="update", record=post)) { ... } // boolean predicate var visible = policyScope(model("Post")); // chainable query builder filter // View <cfif can(action="update", record=post)> [linkTo to editPost, key=post.id] </cfif> // CLI generator wheels generate policy Post // app/policies/PostPolicy.cfc with stubbed actionsLives in:
vendor/wheels/auth/Policy.cfc(base) + controller/view mixins undervendor/wheels/controller/authorization.cfc. Composes with:AuthMiddleware(setsrequest.currentUser);policyScopereturns a chainable query builder. All helpers MUST bepublicwith$prefix internally (CLAUDE.md anti-pattern 7 —privatemixins are not integrated). Auto-discovery:model("Post")resolves toapp/policies/PostPolicy.cfc. Default-deny when no policy file exists is safer than default-allow.4. File attachments — storage adapters first, model macro second. Ship the disk abstraction now, defer the
hasOneAttachedmacro:// config/settings.cfm set(storageAdapter="local"); set(storageDisks={ local: {root: "/storage/uploads", urlPrefix: "/uploads"}, s3: {bucket: "myapp-prod", region: "us-east-1"}, public: {root: "/public/uploads", urlPrefix: "/uploads"} }); // Service surface var disk = service("storage").use("s3"); disk.put(key="avatars/42.jpg", contents=fileContent); disk.url(key="avatars/42.jpg"); disk.signedUrl(key="avatars/42.jpg", expiresIn=300); disk.delete(key="avatars/42.jpg"); // Phase-2 (separate issue): model macro component extends="Model" { function config() { hasOneAttached(name="avatar", disk="s3"); hasManyAttached(name="documents", disk="s3"); } }Lives in:
vendor/wheels/storage/Disk.cfc,LocalAdapter.cfc,S3Adapter.cfc. Composes with DI container (service("storage")),cffilefor local writes. S3 adapter via plaincfhttpsigned requests — not JAR.Cross-engine notes
authorize()/can()/policyScope()mixins MUST bepublicwith$prefix (CLAUDE.md anti-pattern 7). Avoidobj.map()on lookup tables (cross-engine 1) — usemapInstance().Left()len-guard idiom (cross-engine 8) for path-prefix trimming; avoidcfcachewrappers sinceattributeCollection = argumentsfails on Adobe 2023/2025 (cross-engine 10). Plaincfhttpfor S3 signing — no JVM-version-sensitive JARs.cfwebsocket, Adobe deprecated it, BoxLang has no equivalent. A first-party WS layer would need an HTTP-upgrade-handler middleware path that is engine-portable, which is non-trivial. SSE has none of these problems — favor doubling down.Open questions
before()returningtrueis allow-all. CLAUDE.md does not prescribe — needs a call.Confidence:
lowAuto-downgrade rule fired on two grounds: (a) the issue is broad enough that "what should the API be" for each of the four features is itself a multi-week design conversation; (b) all four proposals require new infrastructure (a generator subsystem, a
wheels.Policybase + mixins + lookup convention, awheels.storageadapter layer, channel-layer protocol decisions) — each warranting human discussion before code is written. The Topic 4 WS-vs-SSE split between Adonis and the other five frameworks is a non-trivial trade-off where the +1 picks the opposite side.Sources
<!-- wheels-bot:research:2962 -->
docs(web/guides): add Channels pub/sub guide with honest WebSocket st…
bpamiri commentedon Jun 11, 2026
Channels docs slice shipped — proposed sequencing for the remaining roadmap features
The first acceptance criterion is now partially delivered by PR #3004: a user-facing Channels guide under
digging-deeper/in the v4.0 guides, documentingpublish()/subscribeToChannel()/channelSSETag(), the memory and database adapters (post-#2940 retention/cleanup behavior), and theWheelsSSEJS client — including an honest statement of the WebSocket story (see below). This comment proposes per-feature sequencing for the rest of the roadmap, per the issue's own "split into per-feature issues once each is scheduled" guidance.What verification of the current code surfaced
channelSpec25,sseSpec22,DatabaseAdapterSpec10, hardened in fix(sse): blank-line framing, DB cursor advance, channel pruning, bounded retention cleanup #2940). The gap really was docs-only..ai/wheels/channels/channels.mdclaims the JS client is served at/wheels/assets/js/wheels-sse.js. It is not — no route invendor/wheels/public/routes.cfmserves that path and the webroot ispublic/. The new guide instructs users to copyvendor/wheels/public/assets/js/wheels-sse.jsinto their app. A tiny follow-up worth scoping: ship the client at a real URL or via a generator snippet.Proposed sequencing (one child issue each)
1. WebSocket parity — document-first, defer implementation pending demand evidence.
The guide now states plainly: Wheels has no client→server WebSocket path; channels are SSE-only by design (proxy-friendly, free auto-reconnect, reuses HTTP auth — the same call AdonisJS made with Transmit). Client→server is a normal HTTP POST that calls
publish(). Before committing to a WS stack, note the engine-portability wall: Lucee hascfwebsocket, Adobe deprecated theirs, BoxLang has none — a first-party WS layer needs an engine-portable HTTP-upgrade path, which is non-trivial. Recommend: open a child issue that gathers concrete user demand ("do you need bidirectional/binary, or did SSE-only read as a deficiency?") before any design work.2.
wheels generate auth— next up; highest leverage per effort.CLI-only surface (
cli/lucli/templates + scaffolding) on top of the existingvendor/wheels/auth/primitives (Authenticator, Session/Token/JWT strategies,AuthMiddleware). Phoenix/Rails-8 shape: User model + sessions/passwords controllers + views + routes + migrations. Hard requirements from this campaign's findings: generated controllers must callsuper.config()(the CSRF footgun), all-named args. Design calls needing a human: registration on/off flag,--strategy=selection, generated-code-you-own upgrade policy.3. Authorization / policy layer — after the auth generator (it gives policies a
currentUserto authorize).Framework-core change:
wheels.Policybase +authorize()/can()/policyScope()controller+view mixins. Cross-engine constraint: mixin helpers must bepublicwith$-prefixed internals (private mixins are not integrated). Key design call: default-deny vs default-allow when no policy exists — Pundit denies, Laravel'sbefore()foot-gun allows; recommend default-deny.4. File attachments — last; split into two phases.
Phase 1: storage-disk abstraction (
vendor/wheels/storage/— local + S3 adapters, signed URLs; S3 via plaincfhttprequest signing, no JARs). Phase 2 (separate issue, only if demand): Rails-stylehasOneAttachedmodel macro with the polymorphic blob/attachment tables. Storage adapters alone cover most of the demand at a fraction of the effort.If this sequencing looks right, I'll open the four child issues and convert this roadmap issue into a tracking checklist.
feat(storage): add storage-disk abstraction with local and S3 drivers (…
feat(auth): PBKDF2 password hashing service (PasswordHasher) (#3288)
feat(controller): authorization policy layer — wheels.Policy + author…
feat(cli): wheels generate auth — session/token/jwt scaffold on the a…
bpamiri commentedon Jul 8, 2026
Status roll-up (2026-07-08) — all four deliverables shipped
For the 4.0.6 cut, here's where this roadmap epic stands. Every tracked slice has landed:
wheels generate auth)wheels-dev/wheels-websocketsv0.2.0 published; tracked in #3292wheels-websockets 0.2.0 (published to the registry —
wheels packages add wheels-websockets): RustCFML native backend + Lucee backend over the official websocket extension. Verified live end-to-end on Lucee 6.2 and on Lucee 7.0.4.34 (with an extension master build); the only outstanding upstream item is a jakarta-compatible extension release (details in #3292). Ongoing package phases (P4 Adobe/BoxLang, demand-gated) stay tracked in #3292, not here.Remaining, deliberately scoped out of this epic and NOT 4.0.6-blocking:
before()hook) — deferred by design.Recommend closing this epic as complete and tracking the two follow-ups above as their own issues when scheduled. Leaving it open for a maintainer close so the phase-2 decision is explicit.
docs(web/guides): document wheels-websockets package; correct legacy …