diff --git a/.context/admin-ui.md b/.context/admin-ui.md
new file mode 100644
index 0000000000..7e60dba397
--- /dev/null
+++ b/.context/admin-ui.md
@@ -0,0 +1,348 @@
+# Admin UI: Data Manager, Applications & Navigation
+
+## Data Manager
+
+Data Manager provides automatic CRUD admin UI for any Preside Object.
+
+### Enable for an Object
+
+```cfml
+/**
+ * @datamanagerEnabled true
+ * @datamanagerGroup blog
+ * @labelfield title
+ * @datamanagerGridFields title,author,published,datecreated
+ * @datamanagerSortableFields title,datecreated
+ * @datamanagerSearchFields title,body
+ * @datamanagerDefaultSortOrder datecreated desc
+ * @datamanagerAllowedOperations read,add,edit,delete,clone,viewversions
+ * @datamanagerAllowDrafts true
+ * @datamanagerModalView true
+ * @datamanagerTreeView true
+ * @datamanagerTreeParentProperty parent_category
+ * @datamanagerTreeSortOrder sort_order
+ */
+component {
+ property name="title" type="string" dbtype="varchar" maxlength="200" required=true;
+}
+```
+
+**Allowed operations:** `read`, `add`, `edit`, `batchedit`, `delete`, `batchdelete`, `clone`, `viewversions`
+
+### i18n for Data Manager
+
+```properties
+# /i18n/preside-objects/blog_post.properties
+title=Blog Posts
+title.singular=Blog Post
+description=Manage blog posts
+iconclass=fa-pencil
+
+field.title.title=Title
+field.title.help=The blog post title
+field.title.placeholder=Enter title
+
+field.published.title=Published
+field.published.listing.title=Pub? # Short column heading
+```
+
+### Data Manager Groups
+
+```properties
+# /i18n/preside-objects/groups/blog.properties
+title=Blog
+description=Blog content management
+iconclass=fa-comments
+```
+
+### Form Conventions
+
+| Form | Path |
+|------|------|
+| Default (add + edit) | `/forms/preside-objects/{objectName}.xml` |
+| Add only | `/forms/preside-objects/{objectName}/admin.add.xml` |
+| Edit only | `/forms/preside-objects/{objectName}/admin.edit.xml` |
+| Quick-add modal | `/forms/preside-objects/{objectName}/admin.quickadd.xml` |
+| Quick-edit modal | `/forms/preside-objects/{objectName}/admin.quickedit.xml` |
+| Clone | `/forms/preside-objects/{objectName}/admin.clone.xml` |
+| Translate | `/forms/preside-objects/_translation_{objectName}/admin.edit.xml` |
+
+### Data Manager Customization Handler
+
+Create `/handlers/admin/datamanager/{objectName}.cfc` to override Data Manager behaviour:
+
+```cfml
+component extends="preside.system.base.AdminHandler" {
+
+ // Override form names
+ private string function getEditRecordFormName( event, rc, prc, args={} ) {
+ return "preside-objects.blog_post.admin.edit.#args.record.status#";
+ }
+
+ // Intercept before/after operations
+ private void function preEditRecordAction( event, rc, prc, args={} ) {
+ // Runs before save
+ }
+
+ private void function postEditRecordAction( event, rc, prc, args={} ) {
+ // Runs after save — args.recordId available
+ clearSearchIndex( args.recordId );
+ }
+
+ private void function preAddRecordAction( event, rc, prc, args={} ) {}
+ private void function postAddRecordAction( event, rc, prc, args={} ) {}
+ private void function preDeleteRecordAction( event, rc, prc, args={} ) {}
+ private void function postDeleteRecordAction( event, rc, prc, args={} ) {}
+
+ // Customise grid query
+ private void function preFetchRecordsForGridListing( event, rc, prc, args={} ) {
+ args.extraFilters = args.extraFilters ?: [];
+ args.extraFilters.append({ filter={ author=event.getAdminUserId() } });
+ }
+
+ // Custom action buttons per record
+ private array function getExtraRecordActionsForGridListing( event, rc, prc, args={} ) {
+ return [{
+ link = event.buildAdminLink( linkTo="blog.preview", queryString="id=#args.record.id#" )
+ , title = "Preview"
+ , iconClass = "fa-eye"
+ , target = "_blank"
+ }];
+ }
+
+ // Draft preview buttons
+ private array function getDraftPreviewActionButtons( event, rc, prc, args={} ) {
+ return [{
+ title = "Preview on site"
+ , link = event.buildLink( linkTo="blog.view", queryString="id=#args.recordId#&draft=true" )
+ , iconClass = "fa-globe"
+ , target = "_blank"
+ }];
+ }
+}
+```
+
+---
+
+## Admin Applications
+
+Multiple separate admin applications can coexist (e.g. main CMS + Events Manager):
+
+```cfml
+// Config.cfc
+settings.adminApplications.append({
+ id = "ems"
+ , feature = "ems"
+ , accessPermission = "ems.access"
+ , defaultEvent = "admin.ems.index"
+ , activeEventPattern = "^admin\.ems\..*"
+ , layout = "ems" // Uses /layouts/ems.cfm
+});
+
+settings.features.ems = { enabled=true };
+settings.adminPermissions.ems = [ "access" ];
+settings.adminRoles.eventsManager = [ "ems.*" ];
+```
+
+Admin handler for the application:
+```cfml
+// /handlers/admin/Ems.cfc
+component extends="preside.system.base.AdminHandler" {
+
+ public void function preHandler( event, action, eventArguments ) {
+ super.preHandler( argumentCollection=arguments );
+ if ( !isFeatureEnabled("ems") ) { event.notFound(); }
+ if ( !hasCmsPermission("ems.access") ) { event.adminAccessDenied(); }
+ prc.pageIcon = "fa-calendar";
+ }
+
+ public void function index( event, rc, prc ) {
+ prc.pageTitle = "Events Manager";
+ event.setView( "admin/ems/index" );
+ }
+}
+```
+
+---
+
+## Admin Left-Hand Navigation (v10.17.0+)
+
+### Configure Menu Items in Config.cfc
+
+```cfml
+settings.adminSideBarItems = [
+ "sitetree"
+ , "assetmanager"
+ , "datamanager"
+ , "usermanager"
+ , "websiteUserManager"
+ , "systemConfiguration"
+ , "updateManager"
+ , "myCustomItem" // Add custom items
+];
+
+settings.adminMenuItems.myCustomItem = {
+ feature = "myFeature"
+ , permissionKey = "myfeature.access"
+ , activeChecks = { handlerPatterns="^admin\\.myfeature\\..*" }
+ , buildLinkArgs = { linkTo="admin.myfeature.index" }
+ , gotoKey = "m"
+ , icon = "fa-star"
+ , title = "myapp:admin.nav.myfeature"
+ , subMenuItems = [ "mySubItem" ]
+};
+
+settings.adminMenuItems.mySubItem = {
+ activeChecks = { datamanagerObject="my_object" }
+ , buildLinkArgs = { linkTo="datamanager.object", queryString="object=my_object" }
+ , title = "myapp:admin.nav.myobject"
+};
+```
+
+### Dynamic Menu Item Handler
+```cfml
+// /handlers/admin/layout/menuitem/myCustomItem.cfc
+component {
+ private boolean function isActive( args={} ) { return false; }
+ private string function buildLink( args={} ) {
+ return event.buildAdminLink( linkTo="myfeature.index" );
+ }
+ private void function prepare( args={} ) {
+ // Dynamically add children
+ args.subMenuItems.append( getModel("myService").getDynamicNavItems(), true );
+ }
+}
+```
+
+### Legacy Sidebar View (pre-v10.17.0)
+```cfm
+
+
+
+ #renderView( view="/admin/layout/sidebar/_menuItem", args={
+ active = ReFindNoCase( "^admin\.myfeature", event.getCurrentEvent() )
+ , title = translateResource( "myapp:admin.nav.myfeature" )
+ , link = event.buildAdminLink( linkTo="myfeature.index" )
+ , icon = "fa-star"
+ } )#
+
+
+```
+
+---
+
+## Admin System Configuration Menu
+
+```cfml
+// Config.cfc
+settings.adminConfigurationMenuItems = [
+ "usermanager"
+ , "systemConfiguration"
+ , "taskmanager"
+ , "errorLogs"
+ , "auditTrail"
+ , "myConfigSection" // Add custom items
+];
+```
+
+Legacy view for custom config menu item:
+```cfm
+
+
+
+
+
+
+ #translateResource("myapp:admin.config.myfeature")#
+
+
+
+
+```
+
+---
+
+## Admin Handler Conventions
+
+All admin handlers extend `preside.system.base.AdminHandler`:
+
+```cfml
+component extends="preside.system.base.AdminHandler" {
+
+ public void function preHandler( event, action, eventArguments ) {
+ super.preHandler( argumentCollection=arguments );
+ // super.preHandler checks login, sets layout, etc.
+
+ // Add breadcrumb
+ event.addAdminBreadCrumb(
+ title = translateResource( "myapp:breadcrumb.title" )
+ , link = event.buildAdminLink( linkTo="myfeature.index" )
+ );
+ }
+
+ public void function index( event, rc, prc ) {
+ prc.pageTitle = translateResource( "myapp:page.title" );
+ prc.pageSubTitle = translateResource( "myapp:page.subtitle" );
+ prc.pageIcon = "fa-star";
+
+ event.setView( "admin/myfeature/index" );
+ }
+
+ // Action handlers (names must end with "Action" for CSRF protection)
+ public void function saveAction( event, rc, prc ) {
+ var formData = event.getCollectionForForm( "my.form" );
+ var vr = validateForm( "my.form", formData );
+
+ if ( !vr.validated() ) {
+ setNextEvent(
+ url = event.buildAdminLink( linkTo="myfeature.index" )
+ , persistStruct = { validationResult=vr, formData=formData }
+ );
+ }
+
+ getModel("myService").save( formData );
+ setNextEvent( url=event.buildAdminLink( linkTo="myfeature.index" ) );
+ }
+}
+```
+
+### Key Admin Event Methods
+
+```cfml
+event.buildAdminLink( linkTo="handler.action", queryString="id=x" )
+event.addAdminBreadCrumb( title="Title", link=url )
+event.adminAccessDenied()
+event.getAdminUserId()
+event.isAdminRequest()
+hasCmsPermission( "permission.key" )
+translateResource( "bundle:key" )
+renderViewlet( event="admin.myhandler.myviewlet", args={} )
+```
+
+---
+
+## Editable System Settings
+
+Configurable settings stored in DB and editable via the admin:
+
+```cfml
+// 1. Form: /forms/system-config/my-settings.xml
+// 2. i18n: /i18n/system-config/my-settings.properties
+// name=My Settings
+// description=Configure my feature
+
+// 3. (Optional) Register in system config menu via Config.cfc
+```
+
+Retrieve settings:
+```cfml
+// In handlers/views:
+var apiKey = getSystemSetting( category="my-settings", setting="api_key", default="" );
+
+// In services:
+var apiKey = $getPresideSetting( "my-settings", "api_key", "default" );
+var allSettings = $getPresideCategorySettings( "my-settings" );
+
+// Via WireBox DSL:
+property name="apiKey" inject="presidecms:systemsetting:my-settings.api_key";
+```
diff --git a/.context/architecture.md b/.context/architecture.md
new file mode 100644
index 0000000000..8e4c031c34
--- /dev/null
+++ b/.context/architecture.md
@@ -0,0 +1,377 @@
+# Architecture, Config, Extensions & Feature Flags
+
+## Bootstrap & Application Startup
+
+`/system/Bootstrap.cfc` orchestrates startup via `setupApplication()`. Modern apps extend it directly in `Application.cfc`:
+
+```cfml
+// /Application.cfc
+component extends="preside.system.Bootstrap" {
+ this.PRESIDE_APPLICATION_ID = "myapp";
+ this.PRESIDE_APPLICATION_RELOAD_TIMEOUT = 1200;
+ this.PRESIDE_APPLICATION_RELOAD_LOCK_TIMEOUT = 0;
+
+ super.setupApplication(
+ id = this.PRESIDE_APPLICATION_ID
+ , presideSessionManagement = false // optional
+ , sessionTimeout = CreateTimeSpan( 0, 0, 40, 0 )
+ );
+}
+```
+
+**Startup sequence:**
+1. CF mappings established (`/preside`, `/coldbox`, `/app`, `/assets`, `/logs`)
+2. `_fetchInjectedSettings()` — loads `.env`, `.injectedConfiguration`, OS env vars
+3. ColdBox initialised via custom Bootstrap
+4. WireBox maps all services, interceptors registered
+5. `postPresideReload` interception fired
+
+**Key interception points:**
+- `prePresideReload` / `postPresideReload`
+- `onApplicationStart` / `onApplicationEnd`
+- `preProcess` / `postProcess`
+
+---
+
+## Config.cfc
+
+Extends `preside.system.config.Config` and calls `super.configure()`:
+
+```cfml
+// /application/config/Config.cfc
+component extends="preside.system.config.Config" {
+ public void function configure() {
+ super.configure();
+
+ // Override ColdBox settings
+ coldbox.appName = "My Application";
+ coldbox.reinitPassword = "mySecurePassword";
+
+ // Feature flags
+ settings.features.myFeature = { enabled=true };
+
+ // Admin permissions
+ settings.adminPermissions.myapp = [ "access", "manage" ];
+ settings.adminRoles.myRole = [ "myapp.*" ];
+
+ // Navigation
+ settings.adminSideBarItems.append( "myFeatureNav" );
+ }
+
+ // Environment-specific overrides
+ public void function local() {
+ settings.showErrors = true;
+ settings.autoSyncDb = true;
+ settings.developerMode = true;
+ }
+}
+```
+
+### Environment Config Injection
+
+Three mechanisms (processed in order, last wins):
+
+1. **`.env` file** (project root):
+ ```
+ PRESIDE_syncDb=false
+ PRESIDE_forceSsl=true
+ ```
+
+2. **JSON file** (`/application/config/.injectedConfiguration`):
+ ```json
+ { "syncDb": false, "forceSsl": true }
+ ```
+
+3. **OS environment variables** with `PRESIDE_` prefix.
+
+Access injected values via `settings.env.myKey`.
+
+### Developer Mode (per-request reload)
+
+```cfml
+// In local() method:
+settings.developerMode = true; // Reload everything
+
+// Or selectively:
+settings.developerMode = {
+ dbSync = true
+ , flushCaches = true
+ , reloadForms = true
+ , reloadStatic = true
+ , reloadI18n = true
+ , reloadPresideObjects = true
+ , reloadWidgets = true
+ , reloadPageTemplates = true
+};
+```
+
+---
+
+## Directory Structure
+
+```
+/application
+ /config
+ Config.cfc # Main config (extends preside.system.config.Config)
+ LocalConfig.cfc # Local dev overrides (gitignored)
+ Wirebox.cfc # Custom DI mappings (optional)
+ Cachebox.cfc # Custom cache config (optional)
+ Routes.cfm # Custom URL routes (optional)
+ /preside-objects/ # CFC data object definitions
+ /handlers/ # ColdBox event handlers
+ /admin/ # Admin-area handlers
+ /page-types/ # Page type handlers
+ /widgets/ # Widget handlers
+ /formcontrols/ # Custom form controls
+ /renderers/ # Label/content renderers
+ /dataExporters/ # Data export handlers
+ /email/ # Email template handlers
+ /rules/ # Rules engine expressions/contexts
+ /Tasks.cfc # Scheduled task definitions
+ /SelectDataViews.cfc # Named data queries
+ /DataFilters.cfc # Named data filters
+ /services/ # Business logic CFCs
+ /views/ # CFM view templates
+ /forms/ # XML form definitions
+ /preside-objects/ # Forms for data objects
+ /page-types/ # Forms for page types
+ /widgets/ # Widget config forms
+ /i18n/ # .properties resource bundles
+ /preside-objects/ # Object field labels
+ /widgets/ # Widget labels
+ /email/ # Email template strings
+ /roles.properties # Role names
+ /permissions.properties
+ /layouts/ # Page layout CFMs
+ /helpers/ # UDF helper files
+ /extensions/ # Third-party extension packages
+ /extensions_app/ # App-local extensions
+.env # Environment variables (gitignored)
+```
+
+---
+
+## Extension System
+
+Extensions are self-contained packages of Preside functionality.
+
+### Extension Structure
+```
+/my-extension/
+ manifest.json # REQUIRED metadata
+ box.json # CommandBox package metadata
+ ModuleConfig.cfc # Optional: ColdBox module config
+ /config
+ Config.cfc # Extension config (configure(config) signature)
+ Wirebox.cfc # Extension DI mappings (configure(binder) signature)
+ /preside-objects/ # Data objects
+ /handlers/ # Handlers
+ /services/ # Services
+ /views/ # Views
+ /forms/ # Form definitions
+ /i18n/ # Resource bundles
+```
+
+### manifest.json
+```json
+{
+ "id": "preside-ext-my-extension",
+ "title": "My Extension",
+ "author": "Company Name",
+ "version": "1.0.0+0001",
+ "dependsOn": ["preside-ext-another-ext"]
+}
+```
+
+### Extension Config.cfc
+Note: uses `configure(config)` signature, NOT `configure()`:
+```cfml
+component {
+ public void function configure( required struct config ) {
+ var settings = config.settings ?: {};
+
+ // Add feature flags
+ settings.features.myExtFeature = { enabled=true, dependsOn=["admin"] };
+
+ // Add permissions
+ settings.adminPermissions.myext = [ "access", "manage" ];
+
+ // Add interceptors
+ config.interceptors.append({
+ class = "app.extensions.my-extension.interceptors.MyInterceptor"
+ });
+ }
+}
+```
+
+### ColdBox Module in an Extension
+
+Add `ModuleConfig.cfc` to make the extension a ColdBox module. This allows namespaced injection:
+```cfml
+// ModuleConfig.cfc
+component {
+ this.cfmapping = "myext"; // Allows: getInstance("myext:MyService")
+ this.autoMapModels = true;
+ this.modelNamespace = "myext";
+}
+```
+
+---
+
+## Feature Flags
+
+Feature flags are compile-time switches — they affect which files load, not just runtime behaviour.
+
+### Defining Features
+```cfml
+// In Config.cfc:
+settings.features.myFeature = {
+ enabled = true
+ , dependsOn = [ "admin" ] // parent features (v10.27+)
+ , widgets = [ "myWidget" ] // widgets requiring this feature
+ , siteTemplates = [ "*" ] // which site templates expose it
+};
+```
+
+### Applying Features
+
+**Preside Object:**
+```cfml
+/** @feature myFeature */
+component { ... }
+
+// On a single property:
+property name="x" feature="myOtherFeature";
+```
+
+**Handler or Service:**
+```cfml
+/** @feature myFeature || anotherFeature */
+component { ... }
+```
+
+**View (first line):**
+```cfm
+
+```
+
+**Form element:**
+```xml
+
+```
+
+### Feature-Dependent Injection
+```cfml
+property name="optService" inject="featureInjection:myFeature:OptionalService";
+
+function doThing() {
+ if ( $isFeatureEnabled("myFeature") ) {
+ optService.doThing();
+ }
+}
+```
+
+### .presideIgnore.json
+Optimize startup by skipping disabled-feature files. In `Config.cfc`:
+```cfml
+// Dev: write the file on startup
+settings.ignoreFile.read = false;
+settings.ignoreFile.write = true;
+
+// Prod: read the pre-generated file
+settings.ignoreFile.read = true;
+settings.ignoreFile.write = false;
+```
+
+---
+
+## WireBox Dependency Injection
+
+### Service Auto-Discovery
+Services in `/services/`, extension `/services/`, and `/system/services/` are auto-mapped as singletons.
+
+### Injection Annotations
+```cfml
+/**
+ * @presideService true -- injects PresideSuperClass helpers
+ * @singleton true -- (default anyway)
+ * @feature myFeat -- only map if feature enabled
+ * @nowirebox true -- exclude from auto-mapping
+ */
+component {
+ public any function init(
+ /**
+ * @someService.inject someService
+ * @objectDao.inject presidecms:object:my_object
+ * @cache.inject cachebox:MyCache
+ * @setting.inject coldbox:setting:myKey
+ * @lazyDep.inject delayedInjector:otherService
+ * @optDep.inject featureInjection:myFeature:OptService
+ */
+ required any someService,
+ required any objectDao
+ // ...
+ ) {
+ _setSomeService( arguments.someService );
+ return this;
+ }
+}
+```
+
+### Custom WireBox Mappings
+```cfml
+// /application/config/Wirebox.cfc
+component extends="preside.system.config.Wirebox" {
+ public void function configure() {
+ super.configure();
+
+ map("mySpecialService")
+ .asSingleton()
+ .to("app.services.MySpecialService")
+ .initArg( name="rootDir", value=expandPath("/uploads") );
+ }
+}
+```
+
+---
+
+## Interception (Event System)
+
+Register interceptors in `Config.cfc`:
+```cfml
+settings.interceptors.append({
+ class = "app.interceptors.MyInterceptor",
+ properties = {}
+});
+```
+
+Interceptor CFC:
+```cfml
+component extends="coldbox.system.Interceptor" {
+ public void function configure() {}
+
+ public void function postInsertObjectData( event, interceptData ) {
+ var objectName = interceptData.objectName ?: "";
+ if ( objectName == "blog_post" ) {
+ // React to new blog post
+ }
+ }
+}
+```
+
+### Key Interception Points
+| Category | Points |
+|----------|--------|
+| App lifecycle | `prePresideReload`, `postPresideReload` |
+| DB operations | `preInsertObjectData`, `postInsertObjectData`, `preUpdateObjectData`, `postUpdateObjectData`, `preDeleteObjectData`, `postDeleteObjectData`, `preSelectObjectData`, `postSelectObjectData` |
+| Auth | `onLoginSuccess`, `onLoginFailure`, `onLogout` |
+| REST | `onRestRequest`, `preInvokeRestResource`, `postInvokeRestResource` |
+| Email | `preSendEmail`, `postSendEmail`, `onPrepareEmailSendArguments` |
+| Site tree | `preRenderSiteTreePage`, `postRenderSiteTreePage` |
+| Form builder | `preFormBuilderFormSubmission`, `postFormBuilderFormSubmission` |
+| Config | `preSaveSystemConfig` |
+| File download | `preDownloadFile` |
diff --git a/.context/asset-manager.md b/.context/asset-manager.md
new file mode 100644
index 0000000000..f96234e275
--- /dev/null
+++ b/.context/asset-manager.md
@@ -0,0 +1,327 @@
+# Asset Manager
+
+## Configuration
+
+```cfml
+// Config.cfc
+public void function configure() {
+ super.configure();
+
+ // File size limits
+ settings.assetmanager.maxFileSize = 10; // MB, default
+
+ // Add custom file types
+ settings.assetmanager.types.document.pdf = {
+ serveAsAttachment = true
+ , mimeType = "application/pdf"
+ };
+
+ settings.assetmanager.types.video.ogv = {
+ serveAsAttachment = false
+ , mimeType = "video/ogg"
+ };
+
+ // Image derivatives (auto-generated versions)
+ settings.assetmanager.derivatives.leadimage = {
+ permissions = "inherit" // "inherit" or "public"
+ , inEditor = true // Selectable in admin editor
+ , autoQueue = [ "image" ] // Auto-generate for these types
+ , transformations = [
+ { method="resize", args={ width=1200, height=600, maintainAspectRatio=true } }
+ ]
+ };
+
+ settings.assetmanager.derivatives.thumbnail = {
+ permissions = "public"
+ , inEditor = false
+ , autoQueue = [ "image" ]
+ , transformations = [
+ { method="shrinkToFit", args={ width=200, height=200 } }
+ ]
+ };
+
+ settings.assetmanager.derivatives.pdfpreview = {
+ permissions = "public"
+ , autoQueue = [ "document" ]
+ , transformations = [
+ { method="pdfPreview", args={ page=1 }, inputfiletype="pdf", outputfiletype="jpg" }
+ ]
+ };
+
+ // Pre-created folder structure
+ settings.assetmanager.folders.profileImages = {
+ label = "Profile Images"
+ , hidden = false
+ , children = {
+ members = { label="Members", hidden=false }
+ , nonMembers = { label="Non-Members", hidden=false }
+ }
+ };
+
+ // Storage paths (typically set in environment config)
+ settings.assetmanager.location.public = ExpandPath( "/uploads/public" );
+ settings.assetmanager.location.private = ExpandPath( "/uploads/private" );
+ settings.assetmanager.location.trash = ExpandPath( "/uploads/.trash" );
+ settings.assetmanager.location.publicUrl = "//cdn.mysite.com/";
+
+ // Processing queue (v10.11.0+)
+ settings.features.assetQueue.enabled = true;
+ settings.features.assetQueueHeartBeat.enabled = true;
+ settings.assetmanager.queue.concurrency = 4;
+ settings.assetmanager.queue.batchSize = 100;
+}
+```
+
+---
+
+## Image Transformations
+
+Built-in transformation methods:
+
+```cfml
+// shrinkToFit — scale to fit within box, maintain aspect ratio
+{ method="shrinkToFit", args={ width=200, height=200, quality="highPerformance" } }
+// quality options: "highPerformance", "highQuality", "nearest", "bilinear", "bicubic"
+
+// resize — crop/resize to exact dimensions
+{ method="resize", args={ width=800, height=400, maintainAspectRatio=true } }
+
+// pdfPreview — render a PDF page as an image
+{ method="pdfPreview", args={ page=1 }, inputfiletype="pdf", outputfiletype="jpg" }
+```
+
+### Custom Transformation
+
+```cfml
+// Config.cfc
+settings.assetmanager.derivatives.watermarked = {
+ transformations = [
+ { method="resize", args={ width=1200 } }
+ , { method="watermark", args={ opacity=0.3 } }
+ ]
+};
+
+// /handlers/AssetTransformers.cfc
+component {
+ property name="imageManipulationService" inject="imageManipulationService";
+
+ private binary function watermark( event, rc, prc, args={} ) {
+ // args.asset = binary of the image
+ // args.opacity = from derivative config
+ var img = ImageNew( args.asset );
+ // ... apply watermark logic
+ var result = ImageGetBlob( img, "jpg" );
+ return result;
+ }
+}
+```
+
+---
+
+## Using Assets in Preside Objects
+
+```cfml
+// Single image
+property name="profile_image" relationship="many-to-one" relatedTo="asset"
+ allowedTypes="image";
+
+// Multiple files
+property name="attachments" relationship="many-to-many" relatedTo="asset"
+ allowedTypes="document,pdf";
+
+// In admin form (auto-resolved from property):
+//
+
+// Explicit form control:
+//
+```
+
+---
+
+## Building Asset URLs
+
+```cfml
+// Basic asset URL
+event.buildLink( assetId=myRecord.profile_image )
+
+// With derivative
+event.buildLink( assetId=myRecord.profile_image, derivative="thumbnail" )
+
+// Specific version
+event.buildLink( assetId=myRecord.profile_image, versionId=versionId )
+
+// From a service (PresideSuperClass):
+var url = $buildLink( assetId=assetId, derivative="thumbnail" )
+```
+
+---
+
+## Rendering Assets
+
+```cfm
+
+#renderAsset(
+ assetId = myRecord.profile_image
+ , context = "mainContent" // Context for derivative selection
+ , args = { derivative="leadimage", class="hero-image", alt="Page hero" }
+)#
+```
+
+### Configuring renderAsset Contexts
+
+```cfml
+// Config.cfc
+settings.assetmanager.assetContexts.mainContent = {
+ derivatives = [ "leadimage", "thumbnail" ]
+ , defaultDerivative = "leadimage"
+};
+```
+
+---
+
+## Custom Storage Providers
+
+```cfml
+// /services/fileStorage/S3StorageProvider.cfc
+component implements="preside.system.services.fileStorage.StorageProvider" {
+
+ public any function init(
+ required string accessKey
+ , required string secretKey
+ , required string bucketName
+ , required string region
+ ) {
+ variables.s3 = createS3Client( argumentCollection=arguments );
+ return this;
+ }
+
+ public binary function getObject( required string path ) {
+ return variables.s3.getObject( bucket=variables.bucketName, key=arguments.path );
+ }
+
+ public string function putObject(
+ required binary object
+ , required string path
+ , string mimeType = ""
+ , boolean isPrivate = false
+ ) {
+ variables.s3.putObject(
+ bucket = variables.bucketName
+ , key = arguments.path
+ , body = arguments.object
+ , acl = arguments.isPrivate ? "private" : "public-read"
+ );
+ return arguments.path;
+ }
+
+ public boolean function deleteObject( required string path ) {
+ variables.s3.deleteObject( bucket=variables.bucketName, key=arguments.path );
+ return true;
+ }
+
+ public boolean function objectExists( required string path ) {
+ return variables.s3.objectExists( bucket=variables.bucketName, key=arguments.path );
+ }
+
+ public string function getObjectUrl( required string path ) {
+ return "https://#variables.bucketName#.s3.amazonaws.com#arguments.path#";
+ }
+}
+
+// Register provider in Config.cfc:
+settings.storageProviders.s3 = {
+ class = "app.services.fileStorage.S3StorageProvider"
+};
+
+// Form for admin UI config: /forms/storage-providers/s3.xml
+```
+
+---
+
+## Asset Manager Service
+
+```cfml
+property name="assetManagerService" inject="assetManagerService";
+
+// List derivatives
+var derivatives = assetManagerService.listDerivatives();
+var editorDerivs = assetManagerService.listEditorDerivatives();
+
+// Queue derivative generation
+assetManagerService.queueAssetDerivatives(
+ assetId = assetId
+ , derivative = "thumbnail"
+);
+
+// Get asset metadata
+var asset = assetManagerService.getAssetData( assetId );
+// Returns struct: { id, title, filename, type, filesize, ... }
+
+// Get asset dimensions (image)
+var dimensions = assetManagerService.getAssetDimensions(
+ id = assetId
+ , derivativeName = "thumbnail"
+);
+// Returns: { width=200, height=150 }
+
+// Upload an asset programmatically
+var assetId = assetManagerService.addAsset(
+ filePath = tmpFilePath
+ , fileName = "myfile.pdf"
+ , folder = folderIdOrSlug
+ , assetData = { title="My Document", description="..." }
+);
+```
+
+---
+
+## File Download Interception
+
+Control access to private/protected assets:
+
+```cfml
+// /interceptors/AssetAccessControl.cfc
+component extends="coldbox.system.Interceptor" {
+
+ property name="websiteLoginService" inject="provider:websiteLoginService";
+ property name="permService" inject="provider:websitePermissionService";
+
+ public void function configure() {}
+
+ public void function preDownloadFile( event, interceptData ) {
+ var storageProvider = event.getValue( "storageProvider", "" );
+
+ if ( storageProvider == "privateAssets" ) {
+ if ( !websiteLoginService.isLoggedIn() ) {
+ event.accessDenied( reason="LOGIN_REQUIRED" );
+ }
+
+ var userId = websiteLoginService.getLoggedInUserId();
+ if ( !permService.hasPermission( permissionKey="assets.access", userId=userId ) ) {
+ event.accessDenied( reason="INSUFFICIENT_PRIVILEGES" );
+ }
+ }
+ }
+}
+```
+
+---
+
+## Asset Picker Form Control Options
+
+```xml
+
+
+
+```
diff --git a/.context/caching.md b/.context/caching.md
new file mode 100644
index 0000000000..0fd6ac7be4
--- /dev/null
+++ b/.context/caching.md
@@ -0,0 +1,211 @@
+# Caching
+
+## CacheBox Configuration
+
+Preside configures CacheBox in `/system/config/Cachebox.cfc`. Override in `/application/config/Cachebox.cfc`:
+
+```cfml
+// /application/config/Cachebox.cfc
+component extends="preside.system.config.Cachebox" {
+
+ public void function configure() {
+ super.configure( argumentCollection=arguments );
+
+ // Customise page cache
+ cacheBox.caches.PresidePageCache.properties.maxObjects = 50000;
+ cacheBox.caches.PresidePageCache.properties.objectDefaultTimeout = 3600;
+ cacheBox.caches.PresidePageCache.properties.evictionPolicy = "LFU";
+
+ // Add custom cache
+ cacheBox.caches.MyFeatureCache = {
+ provider = "coldbox.system.cache.providers.CacheBoxColdBoxProvider"
+ , properties = {
+ maxObjects = 1000
+ , objectDefaultTimeout = 300
+ , evictionPolicy = "LFU"
+ , objectStore = "ConcurrentStore"
+ }
+ };
+ }
+}
+```
+
+## Built-in Cache Names
+
+| Cache Name | Purpose |
+|------------|---------|
+| `default` | General object cache |
+| `template` | ColdBox template cache |
+| `DefaultQueryCache` | Query result cache |
+| `PermissionsCache` | Admin permission cache |
+| `PresidePageCache` | Full rendered page cache |
+| `PresideRequestCache` | Per-request transient cache |
+
+## Using a Cache in a Service
+
+```cfml
+component {
+ property name="cache" inject="cachebox:MyFeatureCache";
+
+ function getExpensiveData( required string key ) {
+ var cached = cache.get( arguments.key );
+ if ( !IsNull( cached ) ) {
+ return cached;
+ }
+
+ var data = _doExpensiveWork( arguments.key );
+ cache.set( arguments.key, data, 300 ); // 300 second TTL
+ return data;
+ }
+
+ function clearCache( required string key ) {
+ cache.clear( arguments.key );
+ }
+
+ function clearAllCache() {
+ cache.clearAll();
+ }
+}
+```
+
+## Query Caching in selectData
+
+```cfml
+// Queries are cached by default
+blogPostDao.selectData( useCache=true )
+
+// Custom cache timeout (seconds)
+blogPostDao.selectData( useCache=true, cacheTimeout=600 )
+
+// Disable caching
+blogPostDao.selectData( useCache=false )
+```
+
+## Clearing Caches
+
+Via reload tokens:
+```
+/?fwReinitCaches=true # Clear all caches
+/?fwReinitDbSync=true # Sync DB + clear object caches
+/?fwReinitObjects=true # Reload object definitions
+```
+
+Programmatically:
+```cfml
+// Clear the query cache
+cacheBox.getCache("DefaultQueryCache").clearAll();
+
+// Clear specific cache key
+cacheBox.getCache("PresidePageCache").clear("/about-us/");
+```
+
+---
+
+## Full Page Caching
+
+Caches entire rendered HTML pages. Serves cached HTML without executing CFML on subsequent requests.
+
+### Enable
+
+```cfml
+// Config.cfc
+settings.features.fullPageCaching.enabled = true;
+
+// Optional: also cache for logged-in users
+settings.features.fullPageCachingForLoggedInUsers.enabled = true;
+
+// Limit what PRC data is saved with cache entry (reduces memory)
+settings.fullPageCaching.limitCacheData = true;
+settings.fullPageCaching.limitCacheDataKeys.prc = [
+ "_site"
+ , "presidePage"
+ , "currentLayout"
+ , "__presideInlineJs"
+ , "_presideUrlPath"
+];
+```
+
+### Disable Caching Per Request
+
+```cfml
+// In a handler or widget:
+event.cachePage( false ); // Disable for this request
+event.preventPageCache(); // Alternative
+
+// Set custom TTL
+event.setPageCacheTimeout( 3600 ); // Seconds
+
+// In a widget annotation:
+/** @cacheable false */
+private string function index( event, rc, prc, args={} ) { ... }
+```
+
+### Delayed Viewlets (Personalised Regions)
+
+Viewlets marked `delayed=true` are rendered AFTER the cached page is served, allowing personalised content to be injected into cached pages:
+
+```cfm
+
+
+
+
+ #renderViewlet( event="page-types.standard.index", args=args )#
+
+
+
+
+ #renderViewlet( event="widgets.UserGreeting", args={}, delayed=true )#
+
+
+
+#renderViewlet( event="core.navigation.mainNavigation", delayed=true )#
+```
+
+The delayed viewlets mechanism:
+1. Page HTML is served from cache
+2. Placeholders are left for delayed viewlets
+3. Delayed viewlets are then executed and output is inserted
+
+### Full Page Cache Bypass Conditions
+
+The page cache is automatically bypassed when:
+- Request is a POST
+- URL contains `fwreinit` or other reload params
+- `event.cachePage( false )` has been called
+- Widget or handler has `@cacheable false`
+- Admin user is viewing the page (unless `fullPageCachingForLoggedInUsers` is enabled)
+
+---
+
+## Request Cache
+
+Short-lived per-request cache to avoid duplicate DB calls within a single request:
+
+```cfml
+component {
+ property name="requestCache" inject="cachebox:PresideRequestCache";
+
+ function getCurrentUser() {
+ var cached = requestCache.get( "currentUser" );
+ if ( !IsNull(cached) ) { return cached; }
+
+ var user = userService.getLoggedInUser();
+ requestCache.set( "currentUser", user );
+ return user;
+ }
+}
+```
+
+---
+
+## CKEditor Template Cache
+
+Static assets (CSS, JS) are fingerprinted by default. During development, reload them:
+```
+/?fwReinitStatic=true
+```
+
+Or enable auto-reload in developer mode:
+```cfml
+settings.developerMode = { reloadStatic=true };
+```
diff --git a/.context/data-objects.md b/.context/data-objects.md
new file mode 100644
index 0000000000..6a742b3e30
--- /dev/null
+++ b/.context/data-objects.md
@@ -0,0 +1,428 @@
+# Preside Data Objects (ORM)
+
+Preside Objects are CFC files in `/preside-objects/` that define database tables and get an automatic CRUD service. No traditional Hibernate ORM is used.
+
+## Basic Object Definition
+
+```cfml
+// /preside-objects/blog_post.cfc
+/**
+ * @labelfield title
+ */
+component {
+ property name="title" type="string" dbtype="varchar" maxLength="200" required=true;
+ property name="slug" type="string" dbtype="varchar" maxLength="200" required=true uniqueindexes="slug";
+ property name="body" type="string" dbtype="text";
+ property name="published" type="boolean" dbtype="boolean" default=false;
+ property name="publish_date" type="date" dbtype="datetime";
+ property name="author" relationship="many-to-one" relatedTo="security_user";
+ property name="categories" relationship="many-to-many" relatedTo="blog_category";
+}
+```
+
+Table is automatically created/synced as `pobj_blog_post`. Four properties are always auto-added:
+- `id` (varchar 35, UUID, PK)
+- `label` (varchar 250) — driven by `@labelfield`
+- `datecreated` (datetime)
+- `datemodified` (datetime)
+
+## Property Attributes
+
+| Attribute | Description |
+|-----------|-------------|
+| `name` | Field name (required) |
+| `type` | CFML type: `string`, `numeric`, `boolean`, `date` |
+| `dbtype` | DB column type: `varchar`, `int`, `text`, `boolean`, `datetime`, `decimal`, `bigint` |
+| `maxLength` | Required for varchar fields |
+| `required` | NOT NULL constraint |
+| `default` | Static value, or `cfml:Now()`, or `method:myMethod` |
+| `indexes` | e.g. `"idx1,idx2|1"` (compound: `"slug|1"` + `"slug_parent|2"`) |
+| `uniqueindexes` | Unique constraint |
+| `control` | Form control: `textinput`, `textarea`, `richeditor`, `select`, etc. |
+| `renderer` | Content renderer name |
+| `formula` | SQL formula for computed field |
+| `generator` | Auto-value: `UUID`, `slug`, `timestamp`, `nextint`, `hash`, `method:myFunc` |
+| `generate` | When: `insert`, `always`, `never` |
+| `generateFrom` | Source property for slug generator |
+| `enum` | Enum type defined in `settings.enum.myType` |
+| `feature` | Only include if feature enabled |
+| `cloneable` | Include in cloning (default: true for most fields) |
+| `autoTrim` | Trim on save |
+| `ignoreChangesForVersioning` | Don't version this field's changes |
+
+## Component Annotations
+
+```cfml
+/**
+ * @labelfield title
+ * @nolabel true // No label field
+ * @tablename custom_name // Override table name
+ * @tableprefix myapp_ // Override prefix
+ * @versioned false // Disable versioning
+ * @versionOnInsert false // Don't version on insert
+ * @feature myFeature
+ * @defaultFilters activeOnly // Always apply these saved filters
+ * @datamanagerEnabled true
+ * @datamanagerGridFields id,title,datecreated
+ * @datamanagerDefaultSortOrder title asc
+ * @datamanagerAllowedOperations read,add,edit,delete,clone
+ * @datamanagerGroup mygroup // Group in Data Manager nav
+ * @cloneable true
+ * @labelRenderer myRenderer
+ * @tenant site // Data tenancy
+ */
+component { ... }
+```
+
+## Relationships
+
+### Many-to-One (FK column in this table)
+```cfml
+property name="category" relationship="many-to-one" relatedTo="blog_category" required=true;
+```
+Creates `category` varchar(35) FK column in this table.
+
+### One-to-Many (no column here, traverse the other side)
+```cfml
+// On blog_category.cfc:
+property name="posts" relationship="one-to-many" relatedTo="blog_post" relationshipKey="category";
+```
+
+### Many-to-Many (auto pivot table)
+```cfml
+property name="categories" relationship="many-to-many" relatedTo="blog_category";
+// Creates pivot table: pobj_blog_post__join__blog_category
+
+// Custom pivot table name:
+property name="tags" relationship="many-to-many" relatedTo="tag" relatedVia="post_tags";
+
+// Multiple M2M to same object — must use relatedVia:
+property name="primary_tags" relationship="many-to-many" relatedTo="tag" relatedVia="post_primary_tags";
+property name="secondary_tags" relationship="many-to-many" relatedTo="tag" relatedVia="post_secondary_tags";
+```
+
+### SelectData View Relationship (v10.11.0+)
+```cfml
+property name="active_posts" relationship="select-data-view" relatedTo="activeBlogPosts" relationshipKey="category";
+```
+
+## CRUD Service API
+
+Inject the object DAO directly, or use `presideObjectService`:
+
+```cfml
+// Direct DAO injection (preferred)
+property name="blogPostDao" inject="presidecms:object:blog_post";
+
+// Or via service
+property name="presideObjectService" inject="presideObjectService";
+
+// selectData
+var posts = blogPostDao.selectData(
+ selectFields = [ "id", "title", "category.label as cat_name", "Count(comments.id) as comment_count" ]
+ , filter = { published=true }
+ , orderBy = "publish_date desc"
+ , maxRows = 10
+ , startRow = 1
+ , useCache = true
+ , groupBy = "id"
+);
+
+// insertData — returns new record ID
+var newId = blogPostDao.insertData( data={
+ title = "My Post"
+ , body = "Content here"
+ , author = authorId
+ , categories = [ cat1Id, cat2Id ] // M2M as array
+});
+
+// updateData
+blogPostDao.updateData(
+ data = { title="Updated Title", published=true }
+ , filter = { id=postId }
+);
+
+// deleteData
+blogPostDao.deleteData( id=postId );
+// or: blogPostDao.deleteData( filter={ published=false } );
+
+// dataExists
+var exists = blogPostDao.dataExists( filter={ slug="my-post" } );
+
+// selectCount
+var total = blogPostDao.selectData( selectFields=["Count(*) as total"] ).total;
+```
+
+## Filtering
+
+### Simple struct filter
+```cfml
+blogPostDao.selectData( filter={ published=true, author=authorId } );
+```
+
+### SQL filter with params
+```cfml
+blogPostDao.selectData(
+ filter = "published = :published and publish_date > :minDate"
+ , filterParams = {
+ published = { type="bit", value=true }
+ , minDate = { type="timestamp", value=Now() }
+ }
+);
+```
+
+### Cross-relationship filter (dot notation)
+```cfml
+// Filter by related object field
+blogPostDao.selectData( filter={ "category.active"=true } );
+
+// Multi-level
+blogPostDao.selectData( filter={ "category$parent.featured"=true } );
+```
+
+### Extra filters (array of filter structs)
+```cfml
+blogPostDao.selectData(
+ extraFilters = [
+ { filter={ published=true } }
+ , { filter="publish_date > :now", filterParams={ now=Now() } }
+ ]
+);
+```
+
+### Named (saved) filters
+Defined in `Config.cfc`:
+```cfml
+settings.filters.publishedPosts = {
+ filter = "published = :published and publish_date <= :now"
+ , filterParams = { published=true, now=Now() }
+};
+// Or as a function:
+settings.filters.publishedPosts = function( args={}, cbController ) {
+ return { filter={ published=true } };
+};
+```
+Or in `/handlers/DataFilters.cfc`:
+```cfml
+component {
+ private struct function publishedPosts( event, rc, prc, args={} ) {
+ return { filter={ published=true } };
+ }
+}
+```
+Use:
+```cfml
+blogPostDao.selectData( savedFilters=["publishedPosts"] );
+```
+
+### Default filters (always applied)
+```cfml
+/** @defaultFilters publishedPosts,activeOnly */
+component { ... }
+
+// Bypass:
+blogPostDao.selectData( ignoreDefaultFilters=["publishedPosts"] );
+```
+
+## Select Fields & Formulas
+
+```cfml
+// Simple fields
+selectFields = [ "id", "title" ]
+
+// Cross-relationship (join traversal)
+selectFields = [ "category.label as cat_label", "author.display_name as author_name" ]
+
+// Aggregate
+selectFields = [ "Count(comments.id) as comment_count", "Max(comments.datecreated) as last_comment" ]
+
+// Formula property (defined on object)
+property name="full_name" formula="Concat(${prefix}first_name, ' ', ${prefix}last_name)";
+property name="comment_count" formula="agg:count{ comments.id }";
+property name="latest_comment" formula="agg:max{ comments.datecreated }";
+```
+
+## Versioning
+
+By default all objects are versioned. Version tables are named `_version_pobj_objectname`.
+
+```cfml
+// Disable versioning entirely:
+/** @versioned false */
+component { ... }
+
+// Disable version on insert only:
+/** @versioned true @versionOnInsert false */
+component { ... }
+
+// Don't version a specific field's changes:
+property name="_last_sync" ignoreChangesForVersioning=true;
+
+// M2M versioning (default: not versioned)
+property name="tags" relationship="many-to-many" relatedTo="tag" versioned=true;
+
+// Get versions
+var versions = presideObjectService.getRecordVersions( objectName="blog_post", id=postId );
+```
+
+## Cloning
+
+```cfml
+/** @cloneable true */
+component { ... }
+
+// Custom clone handler
+/** @cloneHandler myCloner */
+component { ... }
+
+// /handlers/ObjectCloners/MyCloner.cfc
+component {
+ function clone( objectName, recordId, data={} ) {
+ // return new record ID
+ }
+}
+```
+
+## SelectData Views (v10.11.0+)
+
+Named queries for reuse across the codebase:
+
+```cfml
+// /handlers/SelectDataViews.cfc
+component {
+ private struct function activeBlogPosts( event, rc, prc ) {
+ return {
+ objectName = "blog_post"
+ , filter = { published=true }
+ , selectFields = [ "id", "title", "category" ]
+ , orderBy = "publish_date desc"
+ };
+ }
+}
+
+// Use:
+var posts = presideObjectService.selectView( "activeBlogPosts" );
+
+// Reference as relationship:
+property name="active_posts" relationship="select-data-view" relatedTo="activeBlogPosts" relationshipKey="category";
+```
+
+## Data Tenancy (v10.8.0+)
+
+Automatically scope data by a tenant (e.g. customer, site):
+
+```cfml
+// Config.cfc
+settings.tenancy.customer = {
+ object = "customer"
+ , defaultFk = "customer"
+};
+
+// Apply to object:
+/** @tenant customer */
+component { ... }
+
+// Tenant ID provider:
+// /handlers/tenancy/customer.cfc
+component {
+ private string function getId( event, rc, prc ) {
+ return customerService.getCurrentCustomerId();
+ }
+}
+
+// Bypass tenancy:
+dao.selectData( bypassTenants=["customer"] );
+
+// Use alternative tenant:
+dao.selectData( tenantIds={ customer=otherCustomerId } );
+```
+
+## Label Renderers (v10.8.0+)
+
+Custom display for object pickers:
+
+```cfml
+/** @labelRenderer session_category */
+component { ... }
+
+// /handlers/renderers/labels/session_category.cfc
+component {
+ private array function _selectFields( event, rc, prc ) {
+ return [ "label", "colour" ];
+ }
+ private string function _orderBy( event, rc, prc ) { return "label"; }
+ private string function _renderLabel( event, rc, prc ) {
+ return ' #HtmlEditFormat(arguments.label)#';
+ }
+}
+```
+
+## Data Exporters (v10.8.7+)
+
+```cfml
+// Enable:
+settings.features.dataexport.enabled = true;
+
+// On object:
+/**
+ * @dataExportFields id,title,category,datecreated
+ * @dataExportExpandManytoOneFields true
+ */
+component { ... }
+
+// Custom exporter handler: /handlers/dataExporters/MyFormat.cfc
+/**
+ * @exportFileExtension myext
+ * @exportMimeType application/x-myformat
+ */
+component {
+ private string function export( selectFields, fieldTitles, batchedRecordIterator, meta ) {
+ // return file path
+ }
+}
+```
+
+## Object Extension (Merging)
+
+Objects with the same name across core/extensions/application are merged at runtime:
+
+```cfml
+// /application/preside-objects/page.cfc
+// Adds to the core page object:
+component {
+ property name="custom_field" type="string" dbtype="varchar" maxlength="100";
+ property name="old_field" deleted=true; // remove a property
+ property name="title" maxLength="50"; // override attribute
+}
+```
+
+## ENUM Properties
+
+```cfml
+// Config.cfc
+settings.enum.statusType = [ "draft", "published", "archived" ];
+
+// Object
+property name="status" enum="statusType" type="string" dbtype="varchar" maxlength="20";
+
+// i18n/enum/statusType.properties:
+// draft.label=Draft
+// published.label=Published
+```
+
+## Generated Fields
+
+```cfml
+property name="slug" generator="slug" generateFrom="title" generate="insert";
+property name="unique_id" generator="UUID" generate="insert";
+property name="updated" generator="timestamp" generate="always";
+property name="seq" generator="nextint" generate="insert";
+property name="my_hash" generator="method:calculateHash" generate="always";
+```
+
+## DB Sync Behaviour
+
+- New properties → new columns added
+- Removed properties → column renamed to `_deprecated_fieldname`
+- Tables never deleted when objects removed
+- Adding required field to existing data → exception (use DB migration scripts)
diff --git a/.context/email.md b/.context/email.md
new file mode 100644
index 0000000000..e63b1901fe
--- /dev/null
+++ b/.context/email.md
@@ -0,0 +1,349 @@
+# Email Templating System (v10.8.0+)
+
+## Architecture Overview
+
+```
+Email Layout → Visual wrapper (header, footer, branding)
+ └── Email Template → Content definition + parameter schema
+ └── Recipient Type → Who receives it and how to address them
+```
+
+---
+
+## Email Layouts
+
+Layouts provide the HTML/text wrapper for all emails.
+
+```cfm
+
+
+
+
+
+
+
+
+
+ #args.body#
+
+
+
+
+```
+
+```cfm
+
+
+#args.body#
+
+---
+#args.address#
+Unsubscribe: #args.unsubscribeLink#
+
+```
+
+Optional configuration form for layout settings:
+```xml
+
+
+```
+
+```properties
+# /i18n/email/layout/default.properties
+title=Default Email Layout
+description=Standard branded email layout
+
+field.header_logo.title=Header Logo
+field.footer_colour.title=Footer Colour
+field.address.title=Footer Address
+```
+
+---
+
+## System Email Templates
+
+Three parts: Config.cfc declaration, i18n properties, handler.
+
+### 1. Config.cfc Declaration
+
+```cfml
+settings.email.templates.bookingConfirmation = {
+ recipientType = "websiteUser" // or custom recipient type
+ , parameters = [
+ { id="event_name", required=true }
+ , { id="booking_ref", required=true }
+ , { id="booking_summary", required=false }
+ , { id="edit_link", required=false }
+ ]
+};
+```
+
+### 2. i18n Properties
+
+```properties
+# /i18n/email/template/bookingConfirmation.properties
+title=Booking Confirmation
+description=Sent to customers when they complete a booking
+
+param.event_name.title=Event Name
+param.event_name.description=The name of the event booked
+
+param.booking_ref.title=Booking Reference
+param.booking_summary.title=Booking Summary (HTML)
+param.edit_link.title=Edit Booking Link
+```
+
+### 3. Handler
+
+```cfml
+// /handlers/email/template/BookingConfirmation.cfc
+component {
+
+ property name="bookingService" inject="bookingService";
+
+ // Called at send time to resolve template parameters
+ private struct function prepareParameters(
+ required string bookingId // These names must match args passed to $sendEmail()
+ ) {
+ var booking = bookingService.getBookingDetails( arguments.bookingId );
+
+ return {
+ event_name = booking.event_name
+ , booking_ref = booking.reference
+ , booking_summary = {
+ html = renderView( view="/email/template/bookingConfirmation/_summaryHtml", args={ booking=booking } )
+ , text = renderView( view="/email/template/bookingConfirmation/_summaryText", args={ booking=booking } )
+ }
+ , edit_link = event.buildLink( linkTo="bookings.edit", queryString="id=#arguments.bookingId#" )
+ };
+ }
+
+ // Shown in admin email template editor as preview
+ private struct function getPreviewParameters() {
+ return {
+ event_name = "Example Conference 2025"
+ , booking_ref = "BK-001234"
+ , booking_summary = { html="1 × Full Delegate Pass
", text="1 x Full Delegate Pass" }
+ , edit_link = "https://example.com/bookings/edit/?id=preview"
+ };
+ }
+
+ // Default subject (editor can override)
+ private string function defaultSubject() {
+ return "Your booking confirmation for ${event_name} (Ref: ${booking_ref})";
+ }
+
+ // Default HTML body
+ private string function defaultHtmlBody() {
+ return renderView( view="/email/template/bookingConfirmation/_defaultHtmlBody" );
+ }
+
+ // Default plain text body
+ private string function defaultTextBody() {
+ return renderView( view="/email/template/bookingConfirmation/_defaultTextBody" );
+ }
+
+ // Optional: recipient address override
+ private string function getToAddress( required string recipientId ) {
+ return bookingService.getPrimaryEmail( arguments.recipientId );
+ }
+}
+```
+
+### Default Body View
+
+```cfm
+
+Dear ${recipient:first_name},
+Thank you for booking ${event_name}.
+Your booking reference is: ${booking_ref}
+${booking_summary}
+Manage your booking
+```
+
+Variable substitution in email bodies uses `${param_name}` syntax.
+Recipient variables use `${recipient:property_name}`.
+
+---
+
+## Sending Emails
+
+```cfml
+// From a service/handler (PresideSuperClass):
+$sendEmail(
+ template = "bookingConfirmation"
+ , recipientId = websiteUserId // Resolved by recipient type
+ , args = { bookingId=bookingId } // Passed to prepareParameters()
+);
+
+// With explicit to address (bypasses recipient type lookup):
+$sendEmail(
+ template = "bookingConfirmation"
+ , to = "customer@example.com"
+ , args = { bookingId=bookingId }
+);
+
+// Send to multiple:
+$sendEmail(
+ template = "newsletter"
+ , to = [ "user1@example.com", "user2@example.com" ]
+ , args = {}
+);
+
+// With extra params:
+$sendEmail(
+ template = "bookingConfirmation"
+ , recipientId = userId
+ , args = { bookingId=bookingId }
+ , params = { additionalParam="value" } // Extra template variables
+);
+```
+
+---
+
+## Recipient Types
+
+Control how the email system resolves recipients.
+
+### Built-in Recipient Types
+- `websiteUser` — uses `website_user` object
+- `adminUser` — uses `security_user` object
+- `anonymous` — no recipient tracking
+
+### Custom Recipient Type
+
+```cfml
+// Config.cfc
+settings.email.recipientTypes.eventDelegate = {
+ parameters = [ "first_name", "last_name", "email_address" ]
+ , filterObject = "event_delegate"
+ , gridFields = [ "first_name", "last_name", "email_address" ]
+ , recipientIdLogProperty = "event_delegate_recipient"
+};
+```
+
+```cfml
+// /handlers/email/recipientType/EventDelegate.cfc
+component {
+
+ property name="delegateService" inject="eventDelegateService";
+
+ private struct function prepareParameters( required string recipientId ) {
+ var delegate = delegateService.getDelegate( arguments.recipientId );
+ return {
+ first_name = delegate.first_name
+ , last_name = delegate.last_name
+ , email_address = delegate.email_address
+ };
+ }
+
+ private struct function getPreviewParameters() {
+ return {
+ first_name = "Jane"
+ , last_name = "Doe"
+ , email_address = "jane.doe@example.com"
+ };
+ }
+
+ private string function getToAddress( required string recipientId ) {
+ return delegateService.getDelegate( arguments.recipientId ).email_address;
+ }
+
+ // Optional: filter records for admin "send to segment" UI
+ private struct function getFilterForBulkSend( event, rc, prc ) {
+ return { filter={ active=true } };
+ }
+}
+```
+
+---
+
+## Email Service Providers
+
+Configure SMTP or third-party providers in the admin UI or Config.cfc:
+
+```cfml
+// Config.cfc - register a custom provider
+settings.email.serviceProviders.myProvider = {
+ configForm = "email.serviceprovider.myProvider"
+ , sendAction = "email.serviceprovider.myProvider.send"
+ , validateSettingsAction = "email.serviceprovider.myProvider.validateSettings"
+};
+```
+
+```cfml
+// /handlers/email/serviceProvider/MyProvider.cfc
+component {
+
+ private boolean function send( struct sendArgs={}, struct settings={} ) {
+ var success = myProviderApi.sendMessage(
+ apiKey = settings.api_key
+ , to = sendArgs.to
+ , from = sendArgs.from
+ , subject = sendArgs.subject
+ , html = sendArgs.htmlBody
+ , text = sendArgs.textBody
+ );
+ return success;
+ }
+
+ private any function validateSettings(
+ required struct settings
+ , required any validationResult
+ ) {
+ if ( !Len(Trim(settings.api_key ?: "")) ) {
+ validationResult.addError( "api_key", "email.myprovider:validation.api_key.required" );
+ }
+ return validationResult;
+ }
+}
+```
+
+---
+
+## Email Interception Points
+
+```cfml
+component extends="coldbox.system.Interceptor" {
+ public void function configure() {}
+
+ // Modify send arguments before sending
+ public void function onPrepareEmailSendArguments( event, interceptData ) {
+ // interceptData.sendArgs = { to, from, subject, htmlBody, textBody, ... }
+ interceptData.sendArgs.subject = "[ENV] " & interceptData.sendArgs.subject;
+ }
+
+ // Just before send
+ public void function preSendEmail( event, interceptData ) {
+ // interceptData.sendArgs, interceptData.settings
+ }
+
+ // After send (and after log entry)
+ public void function postSendEmail( event, interceptData ) {
+ logService.logEmailSent( interceptData.sendArgs );
+ }
+}
+```
+
+---
+
+## Email Queue (Async Sending)
+
+By default emails are sent synchronously. Enable queueing for async:
+
+```cfml
+settings.features.emailQueue.enabled = true;
+settings.features.emailQueueHeartBeat.enabled = true; // Auto-processes queue
+```
diff --git a/.context/forms-validation.md b/.context/forms-validation.md
new file mode 100644
index 0000000000..e2acd8678d
--- /dev/null
+++ b/.context/forms-validation.md
@@ -0,0 +1,322 @@
+# Forms & Validation
+
+## Form XML Structure
+
+Forms live in `/forms/` as XML files. Structure: `form > tab > fieldset > field`.
+
+```xml
+
+
+```
+
+## i18n Conventions with i18nBaseUri
+
+Given `i18nBaseUri="system-config.email:"`, the system auto-resolves:
+- Tab title: `system-config.email:tab.{id}.title`
+- Tab description: `system-config.email:tab.{id}.description`
+- Tab icon: `system-config.email:tab.{id}.iconClass`
+- Fieldset title: `system-config.email:fieldset.{id}.title`
+- Field label: `system-config.email:field.{name}.title`
+- Field placeholder: `system-config.email:field.{name}.placeholder`
+- Field help: `system-config.email:field.{name}.help`
+
+For Preside Object forms, the default `i18nBaseUri` is `preside-objects.{objectname}:`.
+
+## Field Binding to Preside Objects
+
+Binding pulls all attributes (control type, validation, i18n) from the object's property:
+
+```xml
+
+
+
+
+
+```
+
+## Form Inheritance & Merging
+
+```xml
+
+
+
+
+
+```
+
+**Auto-merging:** Forms at the same relative path in core → extension → application → site-template are automatically merged. No explicit `extends` needed.
+
+## Standard Form Controls
+
+| Control | Description |
+|---------|-------------|
+| `textinput` | Single-line text |
+| `textarea` | Multi-line text |
+| `richeditor` | CKEditor rich text |
+| `password` | Password input |
+| `emailInput` | Email address |
+| `select` | Dropdown |
+| `radio` | Radio buttons |
+| `checkbox` | Single checkbox |
+| `checkboxList` | Multiple checkboxes |
+| `yesNoSwitch` | Toggle switch |
+| `spinner` | Numeric spinner |
+| `datePicker` | Date picker |
+| `timePicker` | Time picker |
+| `datetimepicker` | Date + time |
+| `objectPicker` | Select related Preside Object records |
+| `manyToManySelect` | Multi-select for M2M |
+| `assetPicker` | Asset Manager picker |
+| `siteTreePagePicker` | Site tree page picker |
+| `linkPicker` | URL/page/asset link picker |
+| `enumSelect` | Dropdown from `settings.enum.myType` |
+| `hidden` | Hidden field |
+| `readonly` | Read-only display |
+| `simpleColourPicker` | Colour picker |
+| `captcha` | CAPTCHA |
+
+## Custom Form Control
+
+Create as a viewlet at `formcontrols.{controlName}.{context}`:
+
+**View-based control** (`/views/formcontrols/myControl/index.cfm`):
+```cfm
+
+ inputName = args.name ?: "";
+ defaultValue = args.defaultValue ?: "";
+ value = HtmlEditFormat( event.getValue( name=inputName, defaultValue=defaultValue ) );
+
+
+
+
+```
+
+**Handler-based control** (`/handlers/formcontrols/MyControl.cfc`):
+```cfml
+component {
+ private string function index( event, rc, prc, args={} ) {
+ args.options = myService.getOptions();
+ return renderView( view="formcontrols/select/index", args=args );
+ }
+}
+```
+
+## Validation Framework
+
+### Auto-Validation from Field Attributes
+- `required="true"` → required validator
+- `minLength="5"` / `maxLength="100"` → length validators
+- `minValue="0"` / `maxValue="100"` → numeric range validators
+- `type="date"` → date format validator
+- `uniqueindexes` on object property → unique index validator (server-side only)
+
+### Explicit Validation Rules
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Common Validators
+`required`, `minLength`, `maxLength`, `rangeLength`, `min`, `max`, `range`, `email`, `sameAs`, `pattern`, `presideObjectUniqueIndex`
+
+### Custom Validator
+```cfml
+/**
+ * @validationProvider
+ */
+component {
+ /**
+ * @validator
+ * @validatorMessage myapp:validation.myvalidator.message
+ */
+ public boolean function myValidator(
+ required string fieldName
+ , required any value
+ , required struct data
+ , required string someParam
+ ) {
+ return !Len(Trim(arguments.value)) || myCheck(arguments.value, arguments.someParam);
+ }
+
+ // Optional: client-side JS
+ public string function myValidator_js() {
+ return "function(value, elem, params){ return !value.length || myJsCheck(value, params.someParam); }";
+ }
+}
+```
+
+Register in `Config.cfc`:
+```cfml
+settings.interceptors.append({ class="app.validators.MyValidator" });
+```
+
+### Using Validation in Handlers
+```cfml
+function savePost( event, rc, prc ) {
+ var formName = "preside-objects.blog_post.admin.edit";
+ var formData = event.getCollectionForForm( formName );
+ var validationResult = validateForm( formName, formData );
+
+ if ( !validationResult.validated() ) {
+ setNextEvent(
+ url = event.buildAdminLink( linkTo="blog.editPost", queryString="id=#rc.id#" )
+ , persistStruct = { validationResult=validationResult, formData=formData }
+ );
+ }
+ // ... save data
+}
+```
+
+### Rendering a Form
+```cfm
+
+```
+
+## Form File Naming Conventions
+
+| Purpose | Path |
+|---------|------|
+| Object add form | `/forms/preside-objects/{objectName}/admin.add.xml` |
+| Object edit form | `/forms/preside-objects/{objectName}/admin.edit.xml` |
+| Object default form (v10.9+) | `/forms/preside-objects/{objectName}.xml` |
+| Page type form | `/forms/page-types/{pageTypeName}.xml` |
+| Widget config form | `/forms/widgets/{widgetName}.xml` |
+| System config form | `/forms/system-config/{categoryName}.xml` |
+| Email layout config | `/forms/email/layout/{layoutId}.xml` |
+| Translation form | `/forms/preside-objects/_translation_{objectName}/admin.edit.xml` |
+
+## Auto-Generated Forms
+
+If no form file exists for an object, Preside auto-generates one from the object's properties. This is usually sufficient for simple objects managed via Data Manager.
+
+## Programmatic Form Creation
+
+```cfml
+var newFormName = formsService.createForm( function( formDefinition ) {
+ formDefinition.addField(
+ tab = "default"
+ , fieldset = "default"
+ , name = "title"
+ , control = "textinput"
+ , required = true
+ , maxLength = 200
+ );
+});
+
+// Based on existing form:
+var newFormName = formsService.createForm(
+ basedOn = "preside-objects.blog_post.admin.edit"
+ , generator = function( formDefinition ) {
+ formDefinition.addField(
+ tab = "default"
+ , fieldset = "default"
+ , name = "extra_field"
+ , control = "textinput"
+ );
+ }
+);
+```
+
+## Getting Form Data from Request
+
+```cfml
+// Get data for submitted form (auto-trims by default in admin)
+var formData = event.getCollectionForForm( "my.form.name" );
+
+// Disable auto-trim:
+var formData = event.getCollectionForForm( formName="my.form.name", autoTrim=false );
+```
+
+## richEditor Field Options
+
+```xml
+
+
+```
+
+Configure toolbars in `Config.cfc`:
+```cfml
+settings.ckeditor.toolbars.minimal = "Bold,Italic,Underline,-,Link,Unlink";
+settings.ckeditor.defaults.toolbar = "full";
+```
diff --git a/.context/handlers-views-viewlets.md b/.context/handlers-views-viewlets.md
new file mode 100644
index 0000000000..1f9f276ce1
--- /dev/null
+++ b/.context/handlers-views-viewlets.md
@@ -0,0 +1,480 @@
+# Handlers, Views, Viewlets, Page Types & Widgets
+
+## Handler Conventions
+
+Preside follows ColdBox's handler conventions. Handlers live in `/handlers/`.
+
+```cfml
+// /handlers/Blog.cfc
+component {
+
+ // Runs before every action in this handler
+ public void function preHandler( event, action, eventArguments ) {
+ // event = RequestContext
+ // rc = event.getCollection() — public (URL/form params)
+ // prc = event.getCollection(private=true) — view data
+ }
+
+ public void function index( event, rc, prc ) {
+ prc.posts = getModel( "blogService" ).listPosts( page=rc.page ?: 1 );
+ prc.totalPages = getModel( "blogService" ).getTotalPages();
+ event.setView( "blog/index" );
+ }
+
+ public void function post( event, rc, prc ) {
+ var postId = rc.id ?: "";
+ if ( !Len(Trim(postId)) ) { event.notFound(); }
+ prc.post = getModel( "blogService" ).getPost( postId );
+ event.setView( "blog/post" );
+ }
+}
+```
+
+### Admin Handler
+```cfml
+component extends="preside.system.base.AdminHandler" {
+
+ public void function preHandler( event, action, eventArguments ) {
+ super.preHandler( argumentCollection=arguments ); // REQUIRED — checks login, sets layout
+ event.addAdminBreadCrumb( title="Blog", link=event.buildAdminLink(linkTo="blog") );
+ }
+
+ public void function index( event, rc, prc ) {
+ prc.pageTitle = "Manage Blog";
+ prc.pageIcon = "fa-pencil";
+ event.setView( "admin/blog/index" );
+ }
+
+ // Action handlers must end with "Action" for CSRF protection
+ public void function savePostAction( event, rc, prc ) { ... }
+}
+```
+
+### Key Event Object Methods
+
+```cfml
+// Views & Rendering
+event.setView( "path/to/view" ) // Set view (omit /views/ prefix)
+event.setLayout( "layoutName" ) // Override layout
+event.noLayout() // Render view without layout
+event.renderData( type="json", data={} ) // Return JSON/XML/text directly
+
+// URLs
+event.buildLink( page=pageId ) // Site tree page URL
+event.buildLink( linkTo="handler.action" ) // Handler URL
+event.buildLink( linkTo="handler.action", queryString="id=x" )
+event.buildAdminLink( linkTo="handler.action" ) // Admin URL
+event.buildLink( assetId=assetId ) // Asset download URL
+event.buildLink( assetId=assetId, derivative="thumb" )
+
+// Request collection
+rc.paramName // URL/form parameters
+prc.paramName // Private collection (view data)
+
+// Site tree context
+event.getCurrentPageId()
+event.getPageProperty( "title" )
+event.getPageProperty( "page_type" )
+event.isCurrentPageActive()
+
+// Admin context
+event.getAdminUserId()
+event.isAdminUser()
+event.isAdminRequest()
+
+// Errors
+event.notFound() // 404
+event.accessDenied() // 403
+event.adminAccessDenied() // Admin 403
+
+// Breadcrumbs
+event.addAdminBreadCrumb( title="Title", link=url )
+event.getBreadCrumbs()
+
+// Caching
+event.cachePage() // Is page being cached? (boolean)
+event.cachePage( false ) // Disable page caching
+event.setPageCacheTimeout( 3600 )
+
+// Announcements
+announceInterception( "myEvent", { key=value } )
+```
+
+---
+
+## Views
+
+Views are CFM files in `/views/`. Accessed as `handler/action.cfm`.
+
+```cfm
+
+
+
+
+
+
+
+
+
+ #prc.posts.teaser#
+
+
+
+
+```
+
+### Preside Object Views (Data-Driven Views)
+
+Bypass handlers entirely — pass data directly to a view:
+
+```cfm
+#renderView(
+ view = "blog/postCard"
+ , presideObject = "blog_post"
+ , filter = { published=true }
+ , orderBy = "publish_date desc"
+ , maxRows = 5
+)#
+```
+
+View declares what fields it needs via `cf_presideparam`:
+```cfm
+
+
+
+
+
+
+
+
+
#args.title#
+
#args.teaser#
+
By #args.author# | #args.comment_count# comments
+
+
+```
+
+---
+
+## Viewlets
+
+Viewlets are self-contained reusable components — a private handler action + a view.
+
+### Creating a Viewlet
+
+**Handler** (`/handlers/MyHandler.cfc`):
+```cfml
+component {
+ private string function recentPosts( event, rc, prc, args={} ) {
+ args.posts = getModel("blogService").getRecent(
+ limit = args.limit ?: 5
+ , category = args.category ?: ""
+ );
+ return renderView( view="/viewlets/recentPosts", args=args );
+ }
+}
+```
+
+**View** (`/views/viewlets/recentPosts.cfm`):
+```cfm
+
+
+
+
+```
+
+**Usage** (from any view or handler):
+```cfm
+#renderViewlet( event="myHandler.recentPosts", args={ limit=3, category="news" } )#
+```
+
+### View-Only Viewlet
+
+If no handler exists, Preside looks for the view directly. Place at `/views/{event/path}/index.cfm`.
+
+### renderViewlet() Options
+
+```cfml
+renderViewlet(
+ event = "handler.action" // Required
+ , args = { key=value } // Passed to handler + view
+ , cache = false // Cache the output
+ , cacheTimeout = 3600 // Cache TTL in seconds
+ , cacheKey = "my-unique-key" // Custom cache key
+ , delayed = false // Render after page cache fetch
+)
+```
+
+---
+
+## Page Types
+
+Page types define custom field groups for site tree pages.
+
+### Structure
+
+```
+/preside-objects/page-types/event.cfc # Data object
+/handlers/page-types/event.cfc # Optional: handler with logic
+/views/page-types/event/index.cfm # Default layout
+/views/page-types/event/featured.cfm # Alternative layout
+/forms/page-types/event.xml # Edit form (optional)
+/i18n/page-types/event.properties # Labels
+```
+
+### Data Object
+
+```cfml
+// /preside-objects/page-types/event.cfc
+/**
+ * @allowedParentPageTypes *
+ * @allowedChildPageTypes none
+ * @showInSiteTree true
+ */
+component {
+ // 'page' FK is auto-added (many-to-one to page object)
+ property name="start_date" type="date" dbtype="date" required=true;
+ property name="end_date" type="date" dbtype="date" required=true;
+ property name="location" type="string" dbtype="varchar" maxLength=200;
+ property name="capacity" type="numeric" dbtype="int";
+}
+```
+
+### View (View-Only Mode)
+```cfm
+
+
+
+
+
+
+
+
+ #args.title#
+ #DateFormat(args.start_date)# – #DateFormat(args.end_date)#
+ #HtmlEditFormat(args.location)#
+
+
+```
+
+### Handler (With Logic)
+```cfml
+// /handlers/page-types/event.cfc
+component {
+ private string function index( event, rc, prc, args={} ) {
+ args.relatedEvents = getModel("eventService").getRelated(
+ eventId = event.getCurrentPageId()
+ );
+ return renderView( view="/page-types/event/index", args=args );
+ }
+}
+```
+
+### i18n Properties
+```properties
+# /i18n/page-types/event.properties
+name=Event Page
+description=A page type for events
+iconclass=fa-calendar
+
+layout.index=Default
+layout.featured=Featured
+
+field.start_date.title=Start Date
+field.end_date.title=End Date
+```
+
+---
+
+## Widgets
+
+Widgets are user-placeable viewlets inserted in rich text editor fields.
+
+### Structure
+
+```
+/forms/widgets/promoBox.xml # Config form (editable by content editors)
+/i18n/widgets/promoBox.properties # Labels
+/handlers/widgets/PromoBox.cfc # Logic (index + optional placeholder)
+/views/widgets/promoBox/index.cfm # Rendered output
+```
+
+### Handler
+```cfml
+// /handlers/widgets/PromoBox.cfc
+component {
+
+ /** @cacheable false */
+ private string function index( event, rc, prc, args={} ) {
+ // args contains editor-configured values
+ return renderView( view="/widgets/promoBox/index", args=args );
+ }
+
+ // Optional: customise placeholder shown in the editor
+ private string function placeholder( event, rc, prc, args={} ) {
+ return "Promo Box: " & HtmlEditFormat( args.title ?: "" );
+ }
+}
+```
+
+### Config Form
+```xml
+
+
+```
+
+### i18n Properties
+```properties
+# /i18n/widgets/promoBox.properties
+title=Promo Box
+description=A promotional content block
+iconclass=fa-megaphone
+placeholder=Promo Box: {1}
+
+field.title.label=Heading
+field.linkUrl.label=Link URL
+field.buttonText.label=Button text
+field.image.label=Image
+```
+
+### Widget Feature Flags & Categories
+```cfml
+// Config.cfc
+settings.features.myFeature = {
+ enabled = true,
+ widgets = [ "promoBox", "heroSlider" ] // Widgets only active with this feature
+};
+```
+
+Filter widget availability in a richeditor field:
+```xml
+
+```
+
+---
+
+## Routing
+
+### Built-in Routes
+| Pattern | Destination |
+|---------|-------------|
+| `/about-us/team/` | Site tree page (via slug matching) |
+| `/admin/...` | Admin handlers via `admin.{handler}.{action}` |
+| `/asset/{id}/` | Asset download |
+| `/asset/{id}/{derivative}/` | Asset derivative |
+
+### Custom Route Handler
+```cfml
+// /handlers/routeHandlers/ProfileRouteHandler.cfc
+component implements="preside.system.routeHandlers.iRouteHandler" {
+
+ // Match incoming URL
+ public boolean function match( required string path, required any event ) {
+ return ReFindNoCase( "^/profile/", arguments.path );
+ }
+
+ // Translate URL to ColdBox event
+ public void function translate( required string path, required any event ) {
+ var action = ReReplace( arguments.path, "^/profile/", "" );
+ action = ListChangeDelims( action, ".", "/" );
+ event.setValue( "event", "profile." & action );
+ }
+
+ // For buildLink() to use this handler
+ public boolean function reverseMatch( required struct buildArgs, required any event ) {
+ return (buildArgs.linkTo ?: "") contains "profile.";
+ }
+
+ public string function build( required struct buildArgs, required any event ) {
+ return "/profile/" & ListChangeDelims( ListRest(buildArgs.linkTo,"."), "/", "." ) & "/";
+ }
+}
+```
+
+Register in `/application/config/Routes.cfm`:
+```cfml
+addRouteHandler( getModel("ProfileRouteHandler") );
+```
+
+---
+
+## Site Tree Navigation Viewlets
+
+```cfm
+
+#renderViewlet( event="core.navigation.mainNavigation", args={ depth=2 } )#
+
+
+#renderViewlet( event="core.navigation.subNavigation", args={ startLevel=2, depth=3 } )#
+
+
+#renderViewlet( event="core.navigation.breadCrumbs" )#
+```
+
+Add breadcrumb from a handler:
+```cfml
+event.addBreadCrumb( title="My Section", link=event.buildLink(linkTo="section.index") );
+```
+
+Intercept navigation to modify menu items:
+```cfml
+public void function onGetMainNavigationMenuItems( event, interceptData ) {
+ // interceptData.menuItems is an array of menu item structs
+ // Add, remove, or modify items here
+}
+```
+
+---
+
+## Full Page Caching
+
+```cfml
+// Config.cfc
+settings.features.fullPageCaching.enabled = true;
+settings.fullPageCaching.limitCacheData = true;
+settings.fullPageCaching.limitCacheDataKeys.prc = [
+ "_site", "presidePage", "currentLayout"
+];
+
+// In a handler/widget — disable caching for this request:
+event.cachePage( false );
+event.setPageCacheTimeout( 3600 ); // Custom TTL
+
+// In a widget — mark as not cacheable:
+/** @cacheable false */
+private string function index( event, rc, prc, args={} ) { ... }
+
+// Render a viewlet AFTER cache is served (for personalised content):
+#renderViewlet( event="widgets.UserGreeting", args=args, delayed=true )#
+```
+
+---
+
+## Draft Content
+
+```cfml
+// Enable drafts on object:
+/**
+ * @datamanagerAllowDrafts true
+ */
+component { ... }
+
+// Or via Data Manager customization handler:
+// getDraftPreviewActionButtons(), getDraftVersionLabelOptions(), etc.
+```
diff --git a/.context/i18n.md b/.context/i18n.md
new file mode 100644
index 0000000000..3ab095a0bb
--- /dev/null
+++ b/.context/i18n.md
@@ -0,0 +1,196 @@
+# Internationalisation (i18n)
+
+## Resource Bundle System
+
+Translations live in `.properties` files under `/i18n/`. Files follow Java `.properties` format.
+
+```properties
+# /i18n/myapp.properties
+page.title=My Application
+welcome.message=Welcome, {1}! You have {2} messages.
+nav.home=Home
+nav.blog=Blog
+```
+
+## URI Format
+
+```
+bundle:key.path
+```
+The bundle name maps to the file: `myapp` → `/i18n/myapp.properties`.
+
+System bundles: `cms`, `preside-objects`, `validation`, `roles`, `permissions`, etc.
+
+```
+cms:sitetree.title
+preside-objects.blog_post:field.title.title
+system-config.email:tab.smtp.title
+validation:required.message
+roles:administrator.title
+permissions:blog.add.title
+```
+
+## translateResource()
+
+Available in **handlers**, **views**, and **services** (via PresideSuperClass):
+
+```cfml
+// Basic translation
+translateResource( "myapp:page.title" )
+
+// With default fallback
+translateResource( uri="myapp:page.title", defaultValue="My App" )
+
+// With substitution {1}, {2}, etc.
+translateResource( uri="myapp:welcome.message", data=[ userName, messageCount ] )
+
+// In views/handlers (short form)
+translateResource( "cms:sitetree.editpage.title" )
+
+// In services (PresideSuperClass prefix)
+$translateResource( "myapp:page.title" )
+$translateResource( uri="myapp:page.title", data=["value"] )
+```
+
+## Bundle File Locations
+
+| Bundle Name | File Path |
+|-------------|-----------|
+| `cms` | `/i18n/cms.properties` |
+| `myapp` | `/i18n/myapp.properties` |
+| `preside-objects.blog_post` | `/i18n/preside-objects/blog_post.properties` |
+| `page-types.event` | `/i18n/page-types/event.properties` |
+| `widgets.promoBox` | `/i18n/widgets/promoBox.properties` |
+| `system-config.email` | `/i18n/system-config/email.properties` |
+| `email.template.booking` | `/i18n/email/template/booking.properties` |
+| `email.layout.default` | `/i18n/email/layout/default.properties` |
+| `roles` | `/i18n/roles.properties` |
+| `permissions` | `/i18n/permissions.properties` |
+| `validation` | `/i18n/validation.properties` |
+| `formbuilder.item-types.textinput` | `/i18n/formbuilder/item-types/textinput.properties` |
+| `dataExporters.CSV` | `/i18n/dataExporters/CSV.properties` |
+| `auditlog.mytype` | `/i18n/auditlog/mytype.properties` |
+| `notifications.myTopic` | `/i18n/notifications/myTopic.properties` |
+| `rules.contexts` | `/i18n/rules/contexts.properties` |
+
+## Preside Object Properties File
+
+Standard keys for a Preside Object:
+
+```properties
+# /i18n/preside-objects/blog_post.properties
+title=Blog Posts
+title.singular=Blog Post
+description=Manage your blog content
+iconclass=fa-pencil
+
+# Field labels (used in forms + admin grid headers)
+field.title.title=Post Title
+field.title.placeholder=Enter the post title...
+field.title.help=The main heading of the post
+
+field.published.title=Published
+field.published.listing.title=Pub? # Short heading for grid column
+field.published.listing.help=Whether post is live
+
+# Fieldset labels
+fieldset.main.title=Content
+fieldset.main.description=Main post content
+
+# Tab labels
+tab.main.title=Post Content
+tab.seo.title=SEO & Metadata
+```
+
+## Forms i18nBaseUri Convention
+
+When a form has `i18nBaseUri="system-config.email:"`:
+
+| Element | Resolved URI |
+|---------|--------------|
+| Tab `id="smtp"` title | `system-config.email:tab.smtp.title` |
+| Tab `id="smtp"` description | `system-config.email:tab.smtp.description` |
+| Tab `id="smtp"` icon | `system-config.email:tab.smtp.iconClass` |
+| Fieldset `id="connection"` title | `system-config.email:fieldset.connection.title` |
+| Field `name="server"` label | `system-config.email:field.server.title` |
+| Field `name="server"` placeholder | `system-config.email:field.server.placeholder` |
+| Field `name="server"` help text | `system-config.email:field.server.help` |
+
+## Multilingual Content (v10.8.0+)
+
+Enable the feature and mark objects/properties as multilingual:
+
+```cfml
+// Config.cfc
+settings.features.multilingual.enabled = true;
+```
+
+```cfml
+// /preside-objects/article.cfc
+/**
+ * @multilingual true
+ */
+component {
+ property name="title" type="string" dbtype="varchar" maxlength="200" multilingual=true;
+ property name="body" type="string" dbtype="text" multilingual=true;
+ property name="sku" type="string" dbtype="varchar" maxlength="50"; // NOT multilingual
+}
+```
+
+Set language per request:
+```cfml
+// /handlers/General.cfc
+component extends="preside.system.handlers.General" {
+ function requestStart( event, rc, prc ) {
+ super.requestStart( argumentCollection=arguments );
+ event.setLanguage( "cy_GB" ); // Welsh
+ }
+}
+```
+
+Translation edit form location:
+- `/forms/preside-objects/_translation_{objectName}/admin.edit.xml`
+- `/forms/preside-objects/_translation_page/admin.edit.xml`
+- `/forms/preside-objects/_translation_{pageTypeName}/admin.edit.xml`
+
+List available languages:
+```cfml
+property name="mlService" inject="multilingualPresideObjectService";
+
+var langs = mlService.listLanguages(); // array of language structs
+```
+
+## i18n Service
+
+```cfml
+property name="i18n" inject="coldbox:plugin:i18n";
+
+// Get current locale
+i18n.getFwLocale() // e.g. "en_US"
+i18n.getFWLanguageCode() // e.g. "en"
+i18n.getFWCountryCode() // e.g. "US"
+
+// Translate
+i18n.getResource( bundle="myapp", resource="key.path", locale="cy_GB" )
+
+// Check if URI is valid
+i18n.isValidResourceUri( "myapp:some.key" )
+```
+
+## Adding Languages in Admin
+
+Languages are managed via the Preside admin under System > Languages. Each language needs:
+- Language code (e.g. `cy`)
+- Country code (e.g. `GB`)
+- Name
+
+Translation files use locale suffix: `/i18n/cms_cy_GB.properties` overrides `/i18n/cms.properties` for Welsh (Wales).
+
+## Debug Mode
+
+Enable i18n debug mode to highlight untranslated strings:
+```cfml
+settings.i18n.debugMode = true; // In local() config
+```
+
+Untranslated keys are highlighted in the UI for easy identification.
diff --git a/.context/misc.md b/.context/misc.md
new file mode 100644
index 0000000000..2550ff0420
--- /dev/null
+++ b/.context/misc.md
@@ -0,0 +1,545 @@
+# Miscellaneous: Auditing, Notifications, Sessions, Migrations, Health Checks, CSRF, XSS, Workflow
+
+## Auditing
+
+### Log Audit Events
+
+```cfml
+// In a handler:
+event.audit(
+ action = "blog_post_published"
+ , type = "blog"
+ , recordId = postId
+ , detail = { title=post.title, publishedBy=event.getAdminUserId() }
+);
+
+// In a service (PresideSuperClass):
+$audit(
+ action = "api_token_used"
+ , type = "api"
+ , recordId = tokenId
+ , detail = { endpoint=endpoint }
+);
+```
+
+### Audit i18n
+
+```properties
+# /i18n/auditlog/blog.properties
+title=Blog Management
+iconClass=fa-pencil
+
+blog_post_published.title=Published blog post
+blog_post_published.message={1} published the blog post "{2}"
+blog_post_published.iconClass=fa-check green
+
+blog_post_deleted.title=Deleted blog post
+blog_post_deleted.message={1} deleted the blog post "{2}"
+blog_post_deleted.iconClass=fa-trash red
+```
+
+`{1}` = linked admin user name (auto-resolved), `{2}` etc = from `detail` struct.
+
+### Custom Audit Renderer
+
+```cfml
+// /handlers/renderers/auditLogEntry/Blog.cfc
+component {
+ private string function datatable( event, rc, prc, args={} ) {
+ return "Post " & HtmlEditFormat( args.detail.title ?: "" ) & " was " & args.action;
+ }
+
+ private string function full( event, rc, prc, args={} ) {
+ return renderView( view="/renderers/auditLogEntry/blog/full", args=args );
+ }
+}
+```
+
+---
+
+## Notifications
+
+In-app and email notifications for admin users.
+
+### Register Topic
+
+```cfml
+// Config.cfc
+settings.notificationTopics.append( "bookingCompleted" );
+```
+
+```properties
+# /i18n/notifications/bookingCompleted.properties
+title=Booking Completed
+description=Raised when a customer completes a booking
+iconClass=fa-calendar-check-o
+```
+
+### Raise a Notification
+
+```cfml
+property name="notificationService" inject="notificationService";
+
+notificationService.createNotification(
+ topic = "bookingCompleted"
+ , type = "INFO" // "INFO", "WARNING", or "ALERT"
+ , data = { bookingId=newBookingId }
+);
+```
+
+### Notification Renderers
+
+```cfml
+// /handlers/renderers/notifications/BookingCompleted.cfc
+component {
+
+ property name="bookingService" inject="bookingService";
+
+ // Brief listing view
+ private string function datatable( event, rc, prc, args={} ) {
+ var booking = bookingService.getBooking( args.data.bookingId ?: "" );
+ return "Booking #HtmlEditFormat( booking.reference )# completed";
+ }
+
+ // Detailed view
+ private string function full( event, rc, prc, args={} ) {
+ args.booking = bookingService.getBooking( args.data.bookingId ?: "" );
+ return renderView( view="/renderers/notifications/bookingCompleted/full", args=args );
+ }
+
+ // Email subject
+ private string function emailSubject( event, rc, prc, args={} ) {
+ return "New booking completed: " & (args.data.bookingId ?: "");
+ }
+
+ // Email body
+ private string function emailHtml( event, rc, prc, args={} ) {
+ args.booking = bookingService.getBooking( args.data.bookingId ?: "" );
+ return renderView( view="/renderers/notifications/bookingCompleted/emailHtml", args=args );
+ }
+
+ private string function emailText( event, rc, prc, args={} ) {
+ var booking = bookingService.getBooking( args.data.bookingId ?: "" );
+ return "Booking #booking.reference# completed.";
+ }
+}
+```
+
+### Gritter Toast Notifications (Admin UI)
+
+```cfml
+// In a handler after an action:
+getPlugin("messageBox").info( "Record saved successfully!" );
+getPlugin("messageBox").error( "Failed to save record." );
+getPlugin("messageBox").warning( "Record saved with warnings." );
+```
+
+Configure position in `Config.cfc`:
+```cfml
+settings.adminNotificationsPosition = "bottom-right"; // top-left, top-right, bottom-left, bottom-right
+settings.adminNotificationsSticky = true;
+```
+
+---
+
+## Session Management
+
+### Default (Lucee Sessions)
+Standard CFML session management. Configured in `Application.cfc`:
+```cfml
+super.setupApplication(
+ id = "myapp"
+ , sessionTimeout = CreateTimeSpan( 0, 0, 40, 0 ) // 40 minutes
+);
+```
+
+### Preside Session Management (DB-backed)
+For stateless/containerised deployments:
+```cfml
+super.setupApplication(
+ id = "myapp"
+ , presideSessionManagement = true
+);
+```
+
+### Stateless Requests
+Requests matching these patterns bypass session handling:
+```cfml
+super.setupApplication(
+ id = "myapp"
+ , statelessUrlPatterns = [ "^https?://[^/]+/api/.*" ]
+ , statelessUserAgentPatterns = [ "CFSCHEDULE", "bot\b", "spider\b" ]
+);
+```
+
+### Session Storage Plugin
+```cfml
+property name="sessionStorage" inject="coldbox:plugin:sessionStorage";
+
+sessionStorage.setVar( "key", value );
+var val = sessionStorage.getVar( "key", defaultValue );
+sessionStorage.deleteVar( "key" );
+```
+
+---
+
+## Custom DB Migrations
+
+One-time data migration scripts that run on application startup.
+
+### Migration Handler
+
+```cfml
+// /handlers/dbmigrations/2024-03-15_addDefaultBlogCategories.cfc
+component {
+
+ // Runs synchronously on startup (blocking)
+ private void function run( event, rc, prc ) {
+ var categoryDao = $getPresideObject( "blog_category" );
+
+ if ( !categoryDao.dataExists( filter={ slug="uncategorised" } ) ) {
+ categoryDao.insertData( data={
+ label = "Uncategorised"
+ , slug = "uncategorised"
+ } );
+ }
+ }
+
+ // Optional: runs ~1 minute after startup (non-blocking)
+ private void function runAsync( event, rc, prc ) {
+ _slowDataMigration();
+ }
+
+ // Optional (v10.20.0+): Only run if condition is met
+ private boolean function isEnabled() {
+ return isFeatureEnabled( "blog" );
+ }
+}
+```
+
+**Naming convention:** `YYYY-MM-DD_descriptiveName.cfc` — migrations run in alphabetical order.
+**Each migration must be idempotent** (safe to re-run: check before inserting, use upsert patterns).
+
+---
+
+## System Alerts
+
+Admin alerts for configuration/system issues.
+
+### Alert Check Handler
+
+```cfml
+// /handlers/admin/systemAlerts/CheckEmailConfig.cfc
+component {
+
+ private void function runCheck( required systemAlertCheck check ) {
+ var settings = $getPresideCategorySettings( "email" );
+
+ if ( !Len( Trim( settings.smtp_server ?: "" ) ) ) {
+ check.fail();
+ check.setLevel( "critical" ); // "critical", "warning", or "advisory"
+ check.setData({ message="SMTP server is not configured" });
+ }
+ }
+
+ // Optional: render alert detail in admin UI
+ private string function render( event, rc, prc, args={} ) {
+ return renderView( view="/admin/systemAlerts/checkEmailConfig/render", args=args );
+ }
+
+ private boolean function runAtStartup() { return true; }
+
+ private string function schedule() { return "0 0 */6 * * *"; } // Every 6 hours
+
+ private array function watchSettingsCategories() { return [ "email" ]; }
+
+ private string function defaultLevel() { return "warning"; }
+}
+```
+
+```properties
+# /i18n/systemAlerts/checkEmailConfig.properties
+title=Email Configuration Check
+```
+
+### Trigger Alert Check Programmatically
+
+```cfml
+// In services/handlers:
+runSystemAlertCheck( type="CheckEmailConfig" );
+runSystemAlertCheck( type="CheckDataMappings", reference=recordId, async=true );
+```
+
+### SystemAlertCheck Methods
+
+```cfml
+check.fail()
+check.pass()
+check.setLevel( "critical" | "warning" | "advisory" )
+check.setData( { customData="value" } )
+check.passes() // boolean
+check.fails() // boolean
+check.getType()
+check.getReference()
+check.getTrigger() // "startup", "settings", "schedule", "code", "rerun"
+```
+
+---
+
+## Health Checks
+
+Periodic checks of external service availability.
+
+### Configure
+
+```cfml
+// Config.cfc
+settings.healthcheckServices.ElasticSearch = {
+ interval = CreateTimeSpan( 0, 0, 0, 30 ) // Every 30 seconds
+};
+settings.healthcheckServices.RabbitMQ = {
+ interval = CreateTimeSpan( 0, 0, 1, 0 ) // Every 1 minute
+};
+```
+
+### Handler
+
+```cfml
+// /handlers/healthcheck/ElasticSearch.cfc
+component {
+ property name="elasticSearchService" inject="elasticSearchService";
+
+ private boolean function check() {
+ try {
+ return elasticSearchService.ping();
+ } catch ( any e ) {
+ return false;
+ }
+ }
+}
+```
+
+### Check Status in Code
+
+```cfml
+// In handlers/views:
+if ( isUp("elasticSearch") ) {
+ prc.results = elasticSearchService.search( rc.q );
+} else {
+ prc.results = fallbackSearch( rc.q );
+}
+
+// In services (PresideSuperClass):
+if ( $isDown("elasticSearch") ) {
+ return fallbackSearch( arguments.q );
+}
+```
+
+---
+
+## CSRF Protection
+
+### Admin Protection (Automatic)
+Admin action handlers (names ending in `Action`) are automatically CSRF-protected. No code needed.
+
+```cfml
+// This is protected automatically:
+public void function savePostAction( event, rc, prc ) { ... }
+```
+
+Configure:
+```cfml
+settings.features.adminCsrfProtection.enabled = true; // Default
+settings.csrf.tokenExpiryInSeconds = 3600; // Default: 1200 (20min)
+```
+
+### Frontend CSRF Protection
+
+```cfm
+
+
+```
+
+```cfml
+// In action handler:
+function saveDetails( event, rc, prc ) {
+ if ( !event.validateCsrfToken() ) {
+ setNextEvent(
+ url = editUrl
+ , persistStruct = { error="Invalid security token. Please try again." }
+ );
+ }
+ // ... proceed with save
+}
+```
+
+---
+
+## XSS Protection (AntiSamy)
+
+Automatically sanitises HTML submitted through rich text fields.
+
+```cfml
+// Config.cfc
+settings.antiSamy.enabled = true; // Default: on
+settings.antiSamy.policy = "preside"; // Recommended
+settings.antiSamy.bypassForAdministrators = false; // Default: false
+
+// Available policies:
+// "preside" (recommended, Preside-specific)
+// "antisamy" (default AntiSamy)
+// "tinymce"
+// "ebay"
+// "myspace"
+// "slashdot"
+```
+
+---
+
+## Workflow System (v10.29+)
+
+### DataManager Workflow
+
+Define workflows in YAML and attach to Data Manager objects:
+
+```yaml
+# /workflows/datamanager/article_publishing.yml
+version: 1.0.0
+workflow:
+ id: article_publishing
+
+ initialActions:
+ - id: create
+ result:
+ activateSteps: [ "draft" ]
+
+ steps:
+ - id: draft
+ actions:
+ - id: submit_for_review
+ result:
+ activateSteps: [ "in_review" ]
+
+ - id: in_review
+ actions:
+ - id: approve
+ permission:
+ key: articles.publish
+ result:
+ activateSteps: [ "published" ]
+ appendState:
+ published: true
+ - id: reject
+ form: preside-objects.article.reject
+ result:
+ activateSteps: [ "draft" ]
+
+ - id: published
+ - id: archived
+```
+
+```cfml
+// Object with workflow:
+/**
+ * @datamanagerEnabled true
+ * @datamanagerWorkflowEnabled true
+ * @datamanagerWorkflowDefaultFlow article_publishing
+ * @datamanagerGridFields title,datamanager_workflow_status,published,datecreated
+ */
+component {
+ property name="title" type="string" dbtype="varchar" maxlength="200";
+ property name="published" type="boolean" dbtype="boolean" default=false control="none";
+}
+```
+
+```properties
+# /i18n/datamanagerWorkflow/article_publishing.properties
+title=Article Publishing Workflow
+
+step.draft.title=Draft
+step.in_review.title=In Review
+step.published.title=Published
+
+step.draft.action.submit_for_review.title=Submit for Review
+step.draft.action.submit_for_review.iconClass=fa-paper-plane
+
+step.in_review.action.approve.title=Approve & Publish
+step.in_review.action.approve.iconClass=fa-check
+step.in_review.action.reject.title=Send Back for Revision
+step.in_review.action.reject.iconClass=fa-times
+```
+
+### Workflow Action Handlers
+
+```cfml
+// /handlers/admin/datamanager/article.cfc
+component extends="preside.system.base.AdminHandler" {
+
+ private function canApprove( event, rc, prc, args={}, wfInstance ) {
+ return hasCmsPermission( "articles.publish" );
+ }
+
+ private function preApprove( event, rc, prc, args={}, wfInstance ) {
+ // Called before approve action executes
+ var state = wfInstance.getState();
+ wfInstance.appendState({ approvedBy=event.getAdminUserId() });
+ }
+
+ private function postApprove( event, rc, prc, args={}, wfInstance ) {
+ // Called after approve action completes
+ notificationService.createNotification(
+ topic = "articlePublished"
+ , type = "INFO"
+ , data = { articleId=args.recordId }
+ );
+ }
+}
+```
+
+### Webflow (Multi-Step Frontend Forms)
+
+```yaml
+# /workflows/webflows/registration.yml
+version: 1.0.0
+webflow:
+ id: registration
+ singleton: false # Separate instance per user session
+ steps:
+ - id: personal_details
+ - id: contact_details
+ - id: confirmation
+ finish: true
+```
+
+```cfm
+
+#renderWebflow( "registration" )#
+```
+
+```cfml
+// /handlers/webflow/Registration.cfc
+component {
+
+ private string function personal_details( event, rc, prc, args={}, wfInstance ) {
+ return renderView( view="/webflow/registration/personalDetails", args=args );
+ }
+
+ private void function personal_detailsAction(
+ event, rc, prc, args={}
+ , wfInstance
+ , persistData // Struct that persists between steps
+ , validationResult // Add errors to prevent advancing
+ ) {
+ if ( !Len( Trim( rc.firstName ?: "" ) ) ) {
+ validationResult.addError( "firstName", "First name is required" );
+ }
+ if ( !validationResult.validated() ) { return; }
+
+ persistData.firstName = rc.firstName;
+ persistData.lastName = rc.lastName;
+ }
+}
+```
diff --git a/.context/permissions.md b/.context/permissions.md
new file mode 100644
index 0000000000..74c109b206
--- /dev/null
+++ b/.context/permissions.md
@@ -0,0 +1,255 @@
+# Permissions: Admin CMS & Website Users
+
+## CMS Admin Permissions
+
+### Hierarchy: Permissions → Roles → Groups → Users
+
+### 1. Define Permissions & Roles in Config.cfc
+
+```cfml
+public void function configure() {
+ super.configure();
+
+ // Flat permissions:
+ settings.adminPermissions.blog = [ "navigate", "add", "edit", "delete" ];
+
+ // Nested permissions (produces blog.posts.add, blog.posts.edit, etc.):
+ settings.adminPermissions.blog = {
+ posts = [ "navigate", "add", "edit", "delete" ]
+ , categories = [ "navigate", "add", "edit", "delete" ]
+ };
+
+ // Define a role (collection of permission keys, supports wildcards + negation):
+ settings.adminRoles.blogEditor = [
+ "blog.*" // All blog permissions
+ , "!blog.*.delete" // Except delete
+ , "cms.navigate" // Plus navigate CMS
+ ];
+
+ // Extend an existing role:
+ settings.adminRoles.administrator = settings.adminRoles.administrator ?: [];
+ settings.adminRoles.administrator.append( "blog.*" );
+}
+```
+
+### 2. i18n
+
+```properties
+# /i18n/permissions.properties
+blog.navigate.title=Access Blog section
+blog.navigate.description=Permission to access blog management
+
+blog.posts.add.title=Create Blog Posts
+blog.posts.add.description=Ability to create new blog posts
+
+# /i18n/roles.properties
+blogEditor.title=Blog Editor
+blogEditor.description=Manage blog posts and categories (no delete)
+blogEditor.group=content
+
+roleGroup.content.title=Content Management
+```
+
+### 3. Check Permissions
+
+**In handlers/views:**
+```cfml
+// Simple check
+if ( !hasCmsPermission("blog.posts.add") ) {
+ event.adminAccessDenied();
+}
+
+// Contextual check (e.g. per-folder, per-record)
+if ( !hasCmsPermission(
+ permissionKey = "assetManager.folders.upload"
+ , context = "assetmanagerfolders"
+ , contextKeys = [ currentFolderId ]
+) ) {
+ event.adminAccessDenied();
+}
+```
+
+**In services (PresideSuperClass):**
+```cfml
+$hasAdminPermission( "blog.posts.add" )
+$hasAdminPermission( permissionKey="blog.posts.add", userId=someUserId )
+```
+
+**In views (conditionally show UI):**
+```cfm
+
+ Add Post
+
+```
+
+### 4. Contextual Permissions UI Viewlet
+
+Render a permission management form for a specific context:
+
+```cfm
+#renderViewlet( event="admin.permissions.contextPermsForm", args={
+ permissionKeys = [ "blog.posts.*", "!blog.posts.delete" ]
+ , context = "blogcategory"
+ , contextKey = categoryId
+ , saveAction = event.buildAdminLink(linkTo="blog.savePermsAction", queryString="id=#categoryId#")
+ , cancelAction = event.buildAdminLink(linkTo="blog.manage", queryString="id=#categoryId#")
+} )#
+```
+
+### 5. Admin Permission Service
+
+```cfml
+property name="adminPermissionService" inject="adminPermissionService";
+
+// Check permission
+adminPermissionService.hasPermission( permissionKey="blog.add", userId=userId )
+
+// List roles for display
+adminPermissionService.listRoles() // array of role structs
+adminPermissionService.listRolesWithGroup() // grouped struct
+
+// User groups
+adminPermissionService.listUserGroups( userId=userId )
+adminPermissionService.userHasAssignedRoles( userId=userId, roles=["blogEditor"] )
+
+// Context permissions
+adminPermissionService.getContextPermissions(
+ context = "blogcategory"
+ , contextKeys = [ categoryId ]
+ , permissionKeys = [ "blog.posts.*" ]
+)
+
+adminPermissionService.syncContextPermissions(
+ context = "blogcategory"
+ , contextKey = categoryId
+ , permissionKey = "blog.posts.add"
+ , grantedToGroups = [ groupId1 ]
+ , deniedToGroups = [ groupId2 ]
+)
+```
+
+### 6. System Users (Bypass All Checks)
+
+```cfml
+// Config.cfc
+settings.system_users = "sysadmin,developer";
+// Users with these login IDs bypass all permission checks
+```
+
+---
+
+## Website User Permissions
+
+Separate from CMS permissions — governs access to frontend website resources.
+
+### 1. Define Website Permissions
+
+```cfml
+// Config.cfc
+settings.websitePermissions.comments = [ "add", "edit", "delete" ];
+settings.websitePermissions.documents = [ "download", "upload" ];
+// Core built-ins: pages.access, assets.access
+```
+
+```properties
+# /i18n/permissions.properties
+comments.add.title=Add Comments
+comments.add.description=Ability to post comments
+
+documents.download.title=Download Documents
+documents.download.description=Access to download protected documents
+```
+
+### 2. Website User Objects
+
+- **`website_user`** — Login, email, BCrypt-hashed password
+- **`website_benefit`** — User groups (the singular is "benefit", plural is "benefits")
+- **`website_applied_permission`** — Grants/denies for users/benefits
+
+### 3. Check Website Permissions
+
+**In handlers/views:**
+```cfml
+// Simple
+if ( !hasWebsitePermission("comments.add") ) {
+ event.accessDenied();
+}
+
+// Contextual
+if ( !hasWebsitePermission(
+ permissionKey = "comments.edit"
+ , context = "commentthread"
+ , contextKeys = [ threadId ]
+) ) {
+ event.accessDenied();
+}
+```
+
+**In views:**
+```cfm
+
+ Add Comment
+
+```
+
+**In services:**
+```cfml
+property name="websitePermService" inject="websitePermissionService";
+
+websitePermService.hasPermission(
+ permissionKey = "documents.download"
+ , userId = websiteLoginService.getLoggedInUserId()
+)
+```
+
+### 4. Website Login Service
+
+```cfml
+property name="websiteLoginService" inject="websiteLoginService";
+
+// Login
+var success = websiteLoginService.login(
+ loginId = rc.email
+ , password = rc.password
+ , rememberLogin = IsTrue( rc.rememberMe ?: "" )
+ , rememberExpiryInDays = 30
+);
+
+// Check status
+websiteLoginService.isLoggedIn() // boolean
+websiteLoginService.isAutoLoggedIn() // boolean (from "remember me")
+websiteLoginService.isImpersonated() // boolean (admin impersonating)
+
+// Get user
+websiteLoginService.getLoggedInUserId() // string
+websiteLoginService.getLoggedInUserDetails() // query
+
+// Logout
+websiteLoginService.logout();
+```
+
+### 5. Password Policy
+
+```cfml
+// Config.cfc
+settings.passwordPolicies.website = {
+ minLength = 8
+ , minUpperCase = 1
+ , minNumbers = 1
+ , minSymbols = 0
+};
+```
+
+---
+
+## Quick Reference: Admin vs Website Permissions
+
+| Aspect | Admin (CMS) | Website Users |
+|--------|-------------|---------------|
+| Checking in handler | `hasCmsPermission("key")` | `hasWebsitePermission("key")` |
+| Login check | `$isAdminUserLoggedIn()` | `$isWebsiteUserLoggedIn()` |
+| Access denied | `event.adminAccessDenied()` | `event.accessDenied()` |
+| Service | `adminPermissionService` | `websitePermissionService` |
+| Config key | `settings.adminPermissions` | `settings.websitePermissions` |
+| Roles | `settings.adminRoles` | N/A (uses benefits/groups) |
+| User groups | Admin User Groups | `website_benefit` object |
diff --git a/.context/rest-api.md b/.context/rest-api.md
new file mode 100644
index 0000000000..a66eb8f3b7
--- /dev/null
+++ b/.context/rest-api.md
@@ -0,0 +1,284 @@
+# REST API Framework
+
+## Configuration
+
+```cfml
+// Config.cfc
+settings.rest.path = "/api"; // Default — all REST APIs under /api/
+
+// CORS per API:
+settings.rest.apis[ "/my-api/v1" ] = {
+ corsEnabled = true
+ , corsAllowedOrigins = [ "*" ]
+ , corsAllowedHeaders = [ "Authorization", "Content-Type" ]
+};
+```
+
+---
+
+## Resource Handlers
+
+REST resources live in `/handlers/rest-apis/{api-name}/{version}/`.
+
+### Basic Resource
+
+```cfml
+// /handlers/rest-apis/my-api/v1/Events.cfc
+
+/**
+ * @restUri /events/,/events/{id}/
+ */
+component {
+
+ property name="eventDao" inject="presidecms:object:event";
+
+ // Maps to GET /api/my-api/v1/events/ and GET /api/my-api/v1/events/{id}/
+ private void function get( string id="" ) {
+ if ( Len( Trim( arguments.id ) ) ) {
+ var record = eventDao.selectData(
+ selectFields = [ "id", "title", "start_date", "location" ]
+ , filter = { id=arguments.id }
+ );
+ if ( !record.recordCount ) {
+ restResponse.setStatus( 404, "Not Found" ).noData();
+ return;
+ }
+ restResponse.setData( QueryGetRow( record, 1 ) ).setStatus( 200 );
+ } else {
+ var records = eventDao.selectData(
+ selectFields = [ "id", "title", "start_date" ]
+ , filter = { published=true }
+ , orderBy = "start_date asc"
+ );
+ restResponse.setData( QueryToArray( records ) ).setStatus( 200 );
+ }
+ }
+
+ // Maps to POST /api/my-api/v1/events/
+ private void function post() {
+ var data = deserializeJSON( getHttpRequestData().content );
+ var newId = eventDao.insertData( data=data );
+
+ restResponse
+ .setData({ id=newId })
+ .setStatus( 201, "Created" )
+ .setHeader( "Location", "/api/my-api/v1/events/#newId#/" );
+ }
+
+ // Maps to PUT /api/my-api/v1/events/{id}/
+ private void function put( required string id ) {
+ var data = deserializeJSON( getHttpRequestData().content );
+ eventDao.updateData( data=data, filter={ id=arguments.id } );
+ restResponse.setStatus( 200 ).noData();
+ }
+
+ // Maps to DELETE (method name doesn't match HTTP verb — use @restVerb)
+ /**
+ * @restVerb DELETE
+ */
+ private void function deleteEvent( required string id ) {
+ eventDao.deleteData( filter={ id=arguments.id } );
+ restResponse.setStatus( 200 ).noData();
+ }
+}
+```
+
+URL pattern: `/api/{api-name}/{version}/{restUri}`
+→ `/api/my-api/v1/events/` or `/api/my-api/v1/events/abc123/`
+
+---
+
+## restResponse Object
+
+```cfml
+restResponse.setData( myStruct ) // Response body (auto-serialized to JSON)
+restResponse.setData( queryToArray( query ) ) // Arrays work too
+restResponse.noData() // No response body
+restResponse.setStatus( 200, "OK" ) // HTTP status code + message
+restResponse.setStatus( 404, "Not Found" )
+restResponse.setStatus( 422, "Unprocessable Entity" )
+restResponse.setHeader( "X-Custom-Header", "value" )
+restResponse.setMimeType( "application/json" ) // Default
+restResponse.setRenderer( "myCustomRenderer" )
+restResponse.setError(
+ errorCode = "INVALID_INPUT"
+ , message = "The submitted data is invalid"
+ , detail = { field="title", issue="required" }
+)
+```
+
+---
+
+## restRequest Object
+
+```cfml
+restRequest.getUser() // Authenticated user ID (set by auth provider)
+restRequest.getApi() // API identifier (e.g. "/my-api/v1")
+restRequest.finish() // Stop processing, send current response
+```
+
+---
+
+## Authentication
+
+Auth providers live at `/handlers/rest/auth/{providerId}.cfc`.
+
+### Token-Based Auth Provider
+
+```cfml
+// /handlers/rest/auth/token.cfc
+component {
+
+ property name="apiAuthService" inject="apiAuthService";
+
+ // Return the user ID if authenticated, or empty string
+ private string function authenticate() {
+ var headers = getHttpRequestData( false ).headers;
+ var authHeader = headers.Authorization ?: "";
+
+ if ( !authHeader.startsWith("Bearer ") ) {
+ return "";
+ }
+
+ var token = Mid( authHeader, 8, Len(authHeader) );
+ var userId = apiAuthService.getUserByToken( token );
+
+ if ( !Len( userId ) ) {
+ restResponse.setStatus( 401, "Unauthorized" );
+ restRequest.finish();
+ }
+
+ return userId;
+ }
+}
+```
+
+### Configuring Auth Per API
+
+```cfml
+// Config.cfc
+settings.rest.apis[ "/my-api/v1" ] = {
+ authProvider = "token" // References /handlers/rest/auth/token.cfc
+};
+```
+
+### Accessing Authenticated User
+
+```cfml
+private void function get( string id="" ) {
+ var currentUserId = restRequest.getUser();
+ // Returns empty string if not authenticated
+}
+```
+
+---
+
+## Interception Points
+
+```cfml
+component extends="coldbox.system.Interceptor" {
+ public void function configure() {}
+
+ // At start of every REST request
+ public void function onRestRequest( event, interceptData ) {
+ // interceptData.restRequest, interceptData.restResponse
+ }
+
+ // On unhandled exception
+ public void function onRestError( event, interceptData ) {
+ // interceptData.error, interceptData.restRequest, interceptData.restResponse
+ restResponse.setStatus( 500, "Server Error" )
+ .setError( errorCode="INTERNAL_ERROR", message=interceptData.error.message );
+ }
+
+ // When no matching resource found
+ public void function onMissingRestResource( event, interceptData ) {
+ restResponse.setStatus( 404, "Not Found" ).noData();
+ }
+
+ // Before/after invoking the resource action
+ public void function preInvokeRestResource( event, interceptData ) {}
+ public void function postInvokeRestResource( event, interceptData ) {}
+}
+```
+
+---
+
+## ETag Caching
+
+GET and HEAD responses automatically support ETag-based caching:
+- Response includes `ETag` header (hash of response data)
+- If client sends `If-None-Match` header matching ETag → returns `304 Not Modified`
+
+No code needed; handled automatically by the framework.
+
+---
+
+## URL Structure
+
+```
+/api/{api-name}/{version}/{restUri}
+
+Examples:
+GET /api/my-api/v1/events/
+GET /api/my-api/v1/events/abc123/
+POST /api/my-api/v1/events/
+PUT /api/my-api/v1/events/abc123/
+DELETE /api/my-api/v1/events/abc123/
+
+GET /api/my-api/v1/events/abc123/attendees/
+POST /api/my-api/v1/events/abc123/attendees/
+```
+
+The `@restUri` annotation supports multiple patterns and path params:
+```cfml
+/**
+ * @restUri /events/,/events/{id}/,/events/{id}/attendees/
+ */
+component { ... }
+```
+
+Path parameter names must match function argument names:
+```cfml
+private void function get( string id="", string attendeeId="" ) { ... }
+```
+
+---
+
+## Error Handling
+
+```cfml
+// Return validation errors:
+private void function post() {
+ var data = deserializeJSON( getHttpRequestData().content );
+ var vr = validateForm( "my.form", data );
+
+ if ( !vr.validated() ) {
+ restResponse.setStatus( 422, "Unprocessable Entity" ).setError(
+ errorCode = "VALIDATION_ERROR"
+ , message = "The submitted data failed validation"
+ , detail = vr.getErrors()
+ );
+ return;
+ }
+ // ... create record
+}
+
+// Not found:
+if ( !record.recordCount ) {
+ restResponse.setStatus( 404, "Not Found" ).noData();
+ return;
+}
+
+// Unauthorized:
+if ( !restRequest.getUser().len() ) {
+ restResponse.setStatus( 401, "Unauthorized" ).noData();
+ restRequest.finish();
+}
+
+// Forbidden:
+if ( !hasPermission( restRequest.getUser(), "events.delete" ) ) {
+ restResponse.setStatus( 403, "Forbidden" ).noData();
+ return;
+}
+```
diff --git a/.context/rules-engine.md b/.context/rules-engine.md
new file mode 100644
index 0000000000..6e2d07d36a
--- /dev/null
+++ b/.context/rules-engine.md
@@ -0,0 +1,376 @@
+# Rules Engine: Conditions, Filters & Expressions
+
+## Concepts
+
+| Term | Description |
+|------|-------------|
+| **Expression** | A single evaluatable item — returns true/false |
+| **Condition** | User-configured combination of expressions (AND/OR) for access/display logic |
+| **Filter** | Like a condition but tied to a single Preside Object — produces a database filter |
+| **Context** | The evaluation environment (webrequest, page, user, etc.) |
+| **Field Type** | UI control for expression configuration parameters |
+
+---
+
+## Contexts
+
+Contexts define what data is available when evaluating expressions.
+
+### Configuration
+
+```cfml
+// Config.cfc
+settings.rulesEngine.contexts.webrequest = {
+ subcontexts = [ "user", "page" ] // These contexts' payload is merged in
+};
+settings.rulesEngine.contexts.page = {
+ object = "page" // Object whose records can be used in filter building
+};
+settings.rulesEngine.contexts.user = {
+ object = "website_user"
+};
+```
+
+### i18n
+
+```properties
+# /i18n/rules/contexts.properties
+webrequest.title=Web request
+webrequest.description=Conditions for a web page request
+webrequest.iconClass=fa-globe
+
+page.title=Web page
+page.description=Conditions for a site tree page
+page.iconClass=fa-file-o
+
+user.title=Website user
+user.description=Conditions about the current user
+user.iconClass=fa-user
+```
+
+### Context Payload Handler
+
+Provides the data available to expressions during evaluation:
+
+```cfml
+// /handlers/rules/contexts/User.cfc
+component {
+ private struct function getPayload() {
+ return {
+ user = {
+ id = isWebsiteUserLoggedIn() ? getLoggedInWebsiteUserId() : ""
+ , email = isWebsiteUserLoggedIn() ? getLoggedInWebsiteUserDetails().email_address : ""
+ }
+ };
+ }
+}
+```
+
+---
+
+## Expressions
+
+### Simple Boolean Expression
+
+```cfml
+// /handlers/rules/expressions/UserIsLoggedIn.cfc
+/**
+ * @expressionContexts webrequest
+ */
+component {
+
+ // _is is a magic boolean field — true = "is", false = "is not"
+ private boolean function evaluateExpression( boolean _is=true ) {
+ return arguments._is == isWebsiteUserLoggedIn();
+ }
+}
+```
+
+```properties
+# /i18n/rules/expressions/userIsLoggedIn.properties
+label=User is logged in
+text=User {_is} logged in
+```
+
+### Expression With Fields
+
+```cfml
+// /handlers/rules/expressions/UserHasBookedEvent.cfc
+/**
+ * @expressionContexts webrequest,user
+ */
+component {
+
+ property name="bookingService" inject="bookingService";
+
+ /**
+ * @emsEvent.fieldType object
+ * @emsEvent.object event
+ * @emsEvent.multiple false
+ */
+ private boolean function evaluateExpression(
+ required string emsEvent
+ , boolean _has = true
+ ) {
+ var userId = payload.user.id ?: "";
+
+ if ( !Len(userId) || !Len(arguments.emsEvent) ) {
+ return !arguments._has;
+ }
+
+ var hasBooked = bookingService.userHasBooked(
+ userId = userId
+ , eventId = arguments.emsEvent
+ );
+ return hasBooked == arguments._has;
+ }
+}
+```
+
+```properties
+# /i18n/rules/expressions/userHasBookedEvent.properties
+label=User has booked an event
+text=User {_has} booked {emsEvent}
+```
+
+### Expression With Operators
+
+```cfml
+// /handlers/rules/expressions/UserBookingCount.cfc
+/**
+ * @expressionContexts user
+ */
+component {
+
+ property name="bookingService" inject="bookingService";
+ property name="rulesEngineOperatorService" inject="rulesEngineOperatorService";
+
+ /**
+ * @count.fieldType number
+ */
+ private boolean function evaluateExpression(
+ required numeric count
+ , string _numericOperator = "gt"
+ ) {
+ var bookingCount = bookingService.getUserBookingCount( payload.user.id ?: "" );
+ return rulesEngineOperatorService.compareNumbers(
+ bookingCount
+ , arguments._numericOperator
+ , arguments.count
+ );
+ }
+}
+```
+
+---
+
+## Magic Field Types
+
+Parameters with special names get special UI treatment automatically:
+
+| Parameter Name | Behaviour |
+|----------------|-----------|
+| `_is` | Boolean toggle: "is" / "is not" |
+| `_has`, `_possesses`, `_did`, `_was`, `_are`, `_will`, `_ever`, `_all` | Boolean variants |
+| `_stringOperator` | String comparison dropdown (equals, contains, startsWith, etc.) |
+| `_numericOperator` | Numeric comparison dropdown (gt, gte, lt, lte, eq, neq) |
+| `_dateOperator` | Date comparison dropdown |
+| `_periodOperator` | Period comparison |
+| `_time` | Date/time range picker (past or future) |
+| `_pastTime` | Past time range picker |
+| `_futureTime` | Future time range picker |
+
+---
+
+## Filter Expressions (for DB filtering)
+
+Filter expressions produce database filters (SQL) rather than returning a boolean.
+
+```cfml
+// /handlers/rules/expressions/UserHasBookedEventFilter.cfc
+/**
+ * @expressionContexts user
+ */
+component {
+
+ property name="bookingDao" inject="presidecms:object:booking";
+
+ /**
+ * @objects website_user
+ */
+ private array function prepareFilters(
+ required string emsEvent
+ , boolean _has = true
+ , required string objectName
+ , string filterPrefix = ""
+ ) {
+ var paramName = "event_#CreateUUId()#";
+ var subQueryAlias = "bookings_#CreateUUId()#";
+ var filterParams = { "#paramName#" = { value=arguments.emsEvent, type="cf_sql_varchar" } };
+ var filterSql = "#subQueryAlias#.booking_count #arguments._has ? '>' : '='# 0";
+
+ var subQuery = bookingDao.selectData(
+ getSqlAndParamsOnly = true
+ , selectFields = [ "Count(id) as booking_count", "website_user as id" ]
+ , groupBy = "website_user"
+ , filter = "event = :#paramName#"
+ , filterParams = filterParams
+ );
+
+ return [{
+ filter = filterSql
+ , filterParams = filterParams
+ , extraJoins = [{
+ type = "left"
+ , subQuery = subQuery.sql
+ , subQueryAlias = subQueryAlias
+ , subQueryColumn = "id"
+ , joinToTable = arguments.objectName
+ , joinToColumn = "id"
+ }]
+ }];
+ }
+}
+```
+
+---
+
+## Using Conditions in Code
+
+### Evaluate a condition (true/false check)
+
+```cfml
+property name="rulesEngineConditionService" inject="rulesEngineConditionService";
+
+function shouldShowWidget( required string conditionId ) {
+ if ( !Len( Trim( arguments.conditionId ) ) ) { return true; }
+
+ return rulesEngineConditionService.evaluateCondition(
+ conditionId = arguments.conditionId
+ , context = "webrequest"
+ );
+}
+```
+
+In a view:
+```cfm
+
+
+
+```
+
+### Use a filter (database filtering)
+
+```cfml
+property name="rulesEngineFilterService" inject="rulesEngineFilterService";
+
+function getFilteredUsers( required string filterId ) {
+ var extraFilters = [];
+
+ if ( Len( Trim( arguments.filterId ) ) ) {
+ extraFilters.append(
+ rulesEngineFilterService.prepareFilter(
+ objectName = "website_user"
+ , filterId = arguments.filterId
+ )
+ );
+ }
+
+ return userDao.selectData(
+ filter = { active=true }
+ , extraFilters = extraFilters
+ , orderBy = "display_name asc"
+ );
+}
+```
+
+---
+
+## Auto-Generated Expressions
+
+Preside can auto-generate basic filter expressions from object properties:
+
+```cfml
+// On the object:
+/**
+ * @autoGenerateFilterExpressionsFor website_user.email
+ */
+component {
+ property name="email" autofilter=true; // Included in auto-generation
+ property name="notes" autofilter=false; // Excluded
+}
+
+// On a many-to-many property:
+property name="categories" relationship="many-to-many" relatedTo="category"
+ autoGenerateFilterExpressions=true;
+```
+
+Customize auto-generated expression labels:
+```properties
+# /i18n/preside-objects/blog_post.properties
+field.categories.possesses.truthy=is tagged with
+field.categories.possesses.falsey=is not tagged with
+```
+
+---
+
+## Custom Field Types
+
+```cfml
+// /handlers/rules/fieldtypes/MyPicker.cfc
+component {
+
+ private string function renderConfiguredField( string value="", struct config={} ) {
+ // Render the configured value as human-readable text
+ if ( !Len( Trim( arguments.value ) ) ) {
+ return translateResource( "rules.fieldtypes.MyPicker:not.set" );
+ }
+ return getModel("myService").getLabelForId( arguments.value );
+ }
+
+ private string function renderConfigScreen( string value="", struct config={} ) {
+ // Render the configuration UI (form control)
+ return renderFormControl(
+ name = "value"
+ , type = "objectPicker"
+ , object = "my_object"
+ , label = translateResource( config.fieldLabel ?: "rules.fieldtypes.MyPicker:config.label" )
+ , savedValue = arguments.value
+ , required = true
+ );
+ }
+
+ // Optional: transform value into data usable by evaluateExpression()
+ private any function prepareConfiguredFieldData( string value="", struct config={} ) {
+ return getModel("myService").getDataForId( arguments.value );
+ }
+}
+```
+
+Register custom field type:
+```cfml
+// Config.cfc
+settings.rulesEngine.fieldTypes.myPicker = {
+ handler = "rules.fieldtypes.MyPicker"
+};
+```
+
+---
+
+## Condition Picker in Forms
+
+To let editors pick conditions/filters in forms:
+
+```xml
+
+
+
+
+
+```
diff --git a/.context/services-di.md b/.context/services-di.md
new file mode 100644
index 0000000000..c8ea67b91f
--- /dev/null
+++ b/.context/services-di.md
@@ -0,0 +1,294 @@
+# Services, Dependency Injection & PresideSuperClass
+
+## Service Layer Conventions
+
+Services are CFC files in `/services/`. They're auto-discovered and registered as singletons by WireBox.
+
+### Basic Service
+```cfml
+// /services/BlogService.cfc
+/**
+ * @presideService true
+ * @singleton true
+ */
+component {
+
+ public any function init(
+ /**
+ * @blogPostDao.inject presidecms:object:blog_post
+ * @categoryDao.inject presidecms:object:blog_category
+ */
+ required any blogPostDao,
+ required any categoryDao
+ ) {
+ _setBlogPostDao( arguments.blogPostDao );
+ _setCategoryDao( arguments.categoryDao );
+ return this;
+ }
+
+ public query function listPosts( numeric page=1, numeric perPage=10 ) {
+ return _getBlogPostDao().selectData(
+ filter = { published=true }
+ , orderBy = "publish_date desc"
+ , startRow = ((arguments.page-1) * arguments.perPage) + 1
+ , maxRows = arguments.perPage
+ );
+ }
+
+ // Private getters/setters by convention
+ private any function _getBlogPostDao() { return _blogPostDao; }
+ private void function _setBlogPostDao( required any dao ) { _blogPostDao = arguments.dao; }
+ private any function _getCategoryDao() { return _categoryDao; }
+ private void function _setCategoryDao( required any dao ) { _categoryDao = arguments.dao; }
+}
+```
+
+### Injecting Services into Handlers/Other Services
+```cfml
+// Via property injection
+property name="blogService" inject="blogService";
+
+// Via getModel() in handlers
+var service = getModel("blogService");
+
+// Via getInstance() anywhere
+var service = getInstance("blogService");
+```
+
+---
+
+## PresideSuperClass
+
+Services annotated with `@presideService` get injected with helper methods prefixed `$`. You don't call them on `this` — they're mixed in.
+
+### Declaring a Preside Service
+
+```cfml
+/**
+ * @presideService
+ */
+component {
+ function init() { return this; }
+}
+
+// Equivalent (attribute syntax):
+component presideService {
+ function init() { return this; }
+}
+```
+
+### Data Access Methods
+
+```cfml
+// Get object DAO
+var dao = $getPresideObject( "blog_post" );
+var records = dao.selectData( filter={ published=true } );
+
+// Or get the service directly
+var objService = $getPresideObjectService();
+var records = objService.selectData( objectName="blog_post", filter={ published=true } );
+```
+
+### System Settings
+```cfml
+var apiKey = $getPresideSetting( category="myapp", setting="api_key", default="" );
+var allConfig = $getPresideCategorySettings( category="myapp" );
+```
+
+### Authentication
+```cfml
+// Admin user
+$isAdminUserLoggedIn() // boolean
+$getAdminLoggedInUserId() // string ID
+$getAdminLoggedInUserDetails() // query row
+$hasAdminPermission( "perm.key" ) // boolean
+
+// Website user
+$isWebsiteUserLoggedIn() // boolean
+$getWebsiteLoginService().getLoggedInUserId()
+```
+
+### Feature Flags
+```cfml
+$isFeatureEnabled( "myFeature" ) // boolean
+$isFeatureEnabled( "feat1 or (feat2 and feat3)" ) // boolean expression
+```
+
+### Email
+```cfml
+$sendEmail(
+ template = "bookingConfirmation"
+ , recipientId = userId
+ , args = { bookingId=bookingId }
+);
+```
+
+### Audit Trail
+```cfml
+$audit(
+ action = "blog_post_published"
+ , type = "blog"
+ , recordId = postId
+ , detail = { title=post.title }
+);
+```
+
+### Task Manager
+```cfml
+// Run a scheduled task immediately
+$runTask( taskKey="rebuildSearchIndexes", args={ index="main" } );
+
+// Create an ad-hoc background task
+var taskId = createTask(
+ event = "myHandler.longRunningAction"
+ , args = { someArg=someValue }
+ , runNow = true
+);
+```
+
+### i18n
+```cfml
+$translateResource( "myapp:some.key" )
+$translateResource( uri="myapp:some.key", data=[ "John", 5 ] )
+```
+
+### Helper Functions (v10.11.0+)
+```cfml
+// Access all ColdBox helper UDFs via $helpers
+$helpers.isTrue( someValue )
+$helpers.formatDateTime( myDate )
+```
+
+### Getting Other Services
+```cfml
+$getColdbox() // ColdBox controller
+$getAdminLoginService()
+$getWebsiteLoginService()
+$getAdminPermissionService()
+$getWebsitePermissionService()
+$getEmailService()
+$getNotificationService()
+$getTaskManagerService()
+$getAuditService()
+$getContentRendererService()
+$getValidationEngine()
+$getFeatureService()
+$getErrorLogService()
+$getI18n()
+```
+
+---
+
+## WireBox Injection DSL Reference
+
+| DSL Syntax | What it Injects |
+|------------|-----------------|
+| `inject="myService"` | Service named `myService` |
+| `inject="presidecms:object:blog_post"` | Preside Object DAO for `blog_post` |
+| `inject="cachebox:MyCache"` | Named CacheBox cache |
+| `inject="coldbox:setting:myKey"` | ColdBox setting value |
+| `inject="coldbox:plugin:sessionStorage"` | ColdBox plugin |
+| `inject="delayedInjector:myService"` | Lazy-loaded service (resolved on first use) |
+| `inject="featureInjection:myFeat:MyService"` | Service only if feature enabled |
+
+**`delayedInjector`** is important for avoiding circular dependency issues. Use it when service A depends on service B which depends on service A.
+
+### Property Injection (alternative to constructor)
+```cfml
+component {
+ property name="blogPostDao" inject="presidecms:object:blog_post";
+ property name="cache" inject="cachebox:BlogCache";
+
+ function init() { return this; }
+
+ function getRecentPosts() {
+ return blogPostDao.selectData(
+ filter = { published=true }
+ , maxRows = 5
+ , orderBy = "datecreated desc"
+ );
+ }
+}
+```
+
+### Feature-Dependent Service Injection
+```cfml
+component {
+ property name="searchEngine" inject="featureInjection:elasticSearch:ElasticSearchService";
+
+ function search( required string q ) {
+ if ( $isFeatureEnabled("elasticSearch") ) {
+ return searchEngine.search( arguments.q );
+ }
+ return fallbackSearch( arguments.q );
+ }
+}
+```
+
+---
+
+## Custom WireBox Config
+
+```cfml
+// /application/config/Wirebox.cfc
+component extends="preside.system.config.Wirebox" {
+
+ public void function configure() {
+ super.configure();
+
+ // Map a service with constructor args
+ map("profileImageStorage")
+ .asSingleton()
+ .to("preside.system.services.fileStorage.FileSystemStorageProvider")
+ .initArg( name="rootDirectory", value=expandPath("/uploads/profiles") )
+ .initArg( name="trashDirectory", value=expandPath("/uploads/.trash") )
+ .initArg( name="rootUrl", value="" );
+ }
+}
+```
+
+---
+
+## Service Pattern: Private Getters/Setters
+
+Preside follows a convention of private `_getXxx()` / `_setXxx()` methods for encapsulation:
+
+```cfml
+// Instead of:
+variables.myService = arguments.myService;
+return variables.myService;
+
+// Use:
+private any function _getMyService() {
+ return _myService;
+}
+private void function _setMyService( required any service ) {
+ _myService = arguments.service;
+}
+```
+
+This is a strong codebase convention. Follow it when adding new services.
+
+---
+
+## Transient vs Singleton
+
+By default all services are singletons. For transient (new instance per injection):
+
+```cfml
+/**
+ * @presideService true
+ * @singleton false
+ */
+component {
+ function init() { return this; }
+}
+```
+
+Inject transients via `getInstance()` at call time rather than property injection:
+```cfml
+function doWork() {
+ var worker = getInstance("transientWorker");
+ worker.process( data );
+}
+```
diff --git a/.context/task-manager.md b/.context/task-manager.md
new file mode 100644
index 0000000000..e228a44a05
--- /dev/null
+++ b/.context/task-manager.md
@@ -0,0 +1,255 @@
+# Task Manager: Scheduled & Ad-hoc Background Tasks
+
+## Scheduled Tasks
+
+Define scheduled tasks as private functions in `/handlers/Tasks.cfc` (or `/handlers/ScheduledTasks.cfc`).
+
+```cfml
+// /handlers/Tasks.cfc
+component {
+
+ property name="searchService" inject="searchService";
+
+ /**
+ * @displayName Rebuild search indexes
+ * @displayGroup search
+ * @schedule 0 *\/15 * * * *
+ * @priority 10
+ * @exclusivityGroup search
+ * @timeout 300
+ */
+ private boolean function rebuildSearchIndexes( event, rc, prc, logger ) {
+ logger.info( "Starting rebuild..." );
+
+ try {
+ searchService.rebuildAll( logger=arguments.logger );
+ logger.info( "Rebuild complete." );
+ return true;
+ } catch( any e ) {
+ logger.error( "Rebuild failed: #e.message#" );
+ return false;
+ }
+ }
+
+ /**
+ * @displayName Clean up tmp files
+ * @displayGroup maintenance
+ * @schedule 0 0 2 * * *
+ */
+ private boolean function cleanTmpFiles( event, rc, prc, logger ) {
+ logger.info( "Cleaning tmp files older than 24 hours" );
+ fileService.cleanTmp( maxAgeInHours=24 );
+ return true;
+ }
+}
+```
+
+### 6-Point Cron Schedule Format
+
+```
+S M H DoM Mon DoW
+0 0 * * * * = Every hour at :00
+0 */15 * * * * = Every 15 minutes
+0 30 2 * * 2 = 2:30 AM every Tuesday
+0 0 4 1 * * = 4 AM on the 1st of each month
+```
+
+Fields: Second (0-59), Minute (0-59), Hour (0-23), Day of Month (1-31), Month (1-12), Day of Week (1-7, 1=Monday)
+
+### Task Annotations
+
+| Annotation | Description |
+|------------|-------------|
+| `@displayName` | Human-readable name shown in admin UI |
+| `@displayGroup` | Tab grouping in admin task manager UI |
+| `@schedule` | 6-point cron expression |
+| `@priority` | Order in exclusivity group (lower = higher priority) |
+| `@exclusivityGroup` | Tasks in same group won't run concurrently |
+| `@timeout` | Max execution time in seconds (informational only as of v10.10.0) |
+
+### Logger Methods
+
+The `logger` argument supports:
+```cfml
+logger.info( "Informational message" )
+logger.warn( "Warning message" )
+logger.error( "Error message" )
+logger.fatal( "Fatal message" )
+```
+
+### Check for Interruption
+
+```cfml
+private boolean function longRunningTask( event, rc, prc, logger ) {
+ do {
+ if ( $isInterrupted() ) {
+ logger.warn( "Task interrupted, stopping gracefully" );
+ return false;
+ }
+ _processNextBatch();
+ } while( _hasMoreBatches() );
+ return true;
+}
+```
+
+### Run a Scheduled Task Programmatically
+
+```cfml
+// In services (PresideSuperClass):
+$runTask( taskKey="rebuildSearchIndexes" );
+$runTask( taskKey="rebuildSearchIndexes", args={ index="products" } );
+
+// Via service:
+property name="taskManagerService" inject="taskManagerService";
+taskManagerService.runTask( "rebuildSearchIndexes" );
+```
+
+---
+
+## Ad-hoc Background Tasks (v10.9.0+)
+
+For long-running operations triggered by user actions (imports, exports, etc.).
+
+### Create a Task
+
+```cfml
+// In any handler or service:
+var taskId = createTask(
+ event = "admin.blog.exportPostsInBackground" // Handler event path
+ , args = { format="csv", filter={ published=true } }
+ , runNow = true
+);
+
+// With delay:
+createTask(
+ event = "cleanup.oldFiles"
+ , args = { maxAgeDays=7 }
+ , runIn = CreateTimeSpan( 0, 0, 5, 0 ) // Run in 5 minutes
+);
+
+// With retry logic:
+createTask(
+ event = "integration.syncToExternalApi"
+ , args = { recordIds=selectedIds }
+ , runNow = true
+ , retryInterval = [
+ { tries=2, interval=CreateTimeSpan( 0, 0, 5, 0 ) } // 5 min × 2
+ , { tries=2, interval=CreateTimeSpan( 0, 0, 30, 0 ) } // 30 min × 2
+ , { tries=1, interval=CreateTimeSpan( 0, 1, 0, 0 ) } // 1 hour × 1
+ ]
+);
+```
+
+### Task Handler
+
+```cfml
+// /handlers/admin/Blog.cfc
+component extends="preside.system.base.AdminHandler" {
+
+ // Handler that creates the task and redirects to progress UI
+ public void function exportPosts( event, rc, prc ) {
+ var taskId = createTask(
+ event = "admin.blog.exportPostsInBackground"
+ , args = { format=rc.format ?: "csv" }
+ , runNow = true
+ , adminOwner = event.getAdminUserId()
+ , title = "cms:blog.export.task.title"
+ , resultUrl = event.buildAdminLink(
+ linkTo = "blog.downloadExport"
+ , queryString = "taskId={taskId}"
+ )
+ , returnUrl = event.buildAdminLink( linkTo="blog.index" )
+ );
+
+ setNextEvent( url=event.buildAdminLink(
+ linkTo = "adhoctaskmanager.progress"
+ , queryString = "taskId=" & taskId
+ ) );
+ }
+
+ // The actual background work
+ private void function exportPostsInBackground(
+ event, rc, prc
+ , args = {}
+ , logger // For logging progress messages
+ , progress // For reporting % complete
+ ) {
+ var format = args.format ?: "csv";
+ var posts = blogService.getAllForExport();
+ var total = posts.recordCount;
+ var done = 0;
+
+ var filePath = getTempFile( getTempDirectory(), "BlogExport" );
+ var writer = csvService.createWriter( filePath );
+
+ for ( var post in posts ) {
+ if ( progress.isCancelled() ) {
+ writer.close();
+ FileDelete( filePath );
+ abort;
+ }
+
+ writer.writeRow( [ post.title, post.author, post.datecreated ] );
+ done++;
+
+ if ( !(done mod 50) || done == total ) {
+ progress.setProgress( Int( (done/total) * 100 ) );
+ logger.info( "Exported #done# of #total# posts" );
+ }
+ }
+
+ writer.close();
+
+ // Store result for download handler
+ progress.setResult({ filePath=filePath, format=format });
+ }
+
+ // Download handler reads task result
+ public void function downloadExport( event, rc, prc ) {
+ property name="adhocTaskManagerService" inject="adhocTaskManagerService";
+
+ var task = adhocTaskManagerService.getProgress( rc.taskId ?: "" );
+ var filePath = task.result.filePath ?: "";
+
+ if ( !FileExists(filePath) ) { event.notFound(); }
+
+ header name="Content-Disposition" value='attachment; filename="export.csv"';
+ content reset=true file=filePath type="text/csv" deletefile=true;
+ adhocTaskManagerService.discardTask( rc.taskId );
+ abort;
+ }
+}
+```
+
+### Progress Object Methods
+
+```cfml
+progress.setProgress( 50 ) // 0-100 integer
+progress.isCancelled() // boolean — user hit Cancel
+progress.setResult( { key=value } ) // Store arbitrary result data
+```
+
+### AdHocTaskManagerService Methods
+
+```cfml
+property name="adhocTaskManagerService" inject="adhocTaskManagerService";
+
+adhocTaskManagerService.getProgress( taskId ) // struct with progress, result, status
+adhocTaskManagerService.discardTask( taskId ) // Clean up after completion
+adhocTaskManagerService.cancelTask( taskId ) // Request cancellation
+```
+
+### createTask() Options
+
+| Option | Description |
+|--------|-------------|
+| `event` | Handler event path (required) |
+| `args` | Struct passed to handler as `args` |
+| `runNow` | Boolean — run immediately |
+| `runIn` | TimeSpan — delay before running |
+| `runAt` | DateTime — specific scheduled time |
+| `retryInterval` | Array of `{tries, interval}` retry structs |
+| `adminOwner` | Admin user ID for ownership tracking |
+| `title` | i18n key or text for admin UI display |
+| `resultUrl` | URL to redirect to after completion (`{taskId}` substituted) |
+| `returnUrl` | URL for Cancel/Back button |
diff --git a/.gitignore b/.gitignore
index 670b7045b5..e28a1b0474 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
jmimemagic.log
.twgit*
.zanata-cache
+.claude/settings.local.json
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..207f640678
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,127 @@
+# Preside CMS — LLM Agent Context
+
+Preside CMS is an open-source, enterprise-grade CMS built on **ColdBox MVC** and **CFML (Lucee)**. It adds a rich data layer, admin console, permissioning, multi-site support, and extensibility on top of ColdBox.
+
+- **Docs site**: https://docs.preside.org/
+- **Docs source**: `~/code/elf/Preside-Documentation/docs/`
+- **System source**: `/home/dom/code/elf/Preside-CMS/system/`
+- **Application code**: typically `/application/` relative to webroot
+
+## Topic Context Files
+
+Detailed reference for each topic lives in `.context/`:
+
+| File | Covers |
+|------|--------|
+| [architecture.md](.context/architecture.md) | Bootstrap, Config.cfc, extensions, feature flags, reload, DI |
+| [data-objects.md](.context/data-objects.md) | Preside Objects ORM: properties, relationships, CRUD API, filters, versioning |
+| [forms-validation.md](.context/forms-validation.md) | Forms XML, field controls, inheritance, validation framework |
+| [admin-ui.md](.context/admin-ui.md) | Data Manager, admin applications, left-hand menu, system menu |
+| [handlers-views-viewlets.md](.context/handlers-views-viewlets.md) | Handlers, views, viewlets, page types, widgets, routing |
+| [services-di.md](.context/services-di.md) | Service layer, WireBox DI, PresideSuperClass |
+| [permissions.md](.context/permissions.md) | CMS admin permissions, website user permissions |
+| [i18n.md](.context/i18n.md) | Resource bundles, translateResource, multilingual content |
+| [task-manager.md](.context/task-manager.md) | Scheduled tasks, ad-hoc background tasks, progress tracking |
+| [email.md](.context/email.md) | Email layouts, system templates, recipient types, service providers |
+| [rest-api.md](.context/rest-api.md) | REST resources, routing, auth, response handling |
+| [asset-manager.md](.context/asset-manager.md) | Assets, derivatives, storage providers, transformations |
+| [rules-engine.md](.context/rules-engine.md) | Conditions, filter expressions, contexts, field types |
+| [caching.md](.context/caching.md) | CacheBox config, full-page caching, delayed viewlets |
+| [misc.md](.context/misc.md) | Auditing, notifications, sessions, migrations, health checks, CSRF, XSS, workflow |
+
+## Key Architectural Concepts (Quick Reference)
+
+### Technology Stack
+- **Runtime**: Lucee CFML (5.2.9+)
+- **Framework**: ColdBox MVC + WireBox DI + CacheBox
+- **ORM**: Custom Preside Objects (not Hibernate)
+- **Language**: CFML / CFScript (Lucee dialect)
+
+### Application Layout
+```
+/application
+ /config/Config.cfc # Extends preside.system.config.Config
+ /preside-objects/ # Data object CFCs
+ /handlers/ # ColdBox event handlers
+ /services/ # Business logic (WireBox singletons)
+ /views/ # CFM view templates
+ /forms/ # XML form definitions
+ /i18n/ # .properties resource bundles
+ /layouts/ # Page layout CFMs
+ /extensions/ # Third-party extensions
+ /extensions_app/ # App-local extensions
+.env # Environment variables (PRESIDE_ prefix)
+```
+
+### CFML Syntax Note
+Preside uses **Lucee CFML**. Code is written in CFScript (`.cfc` files) or tag-based (`.cfm` views). CFScript syntax:
+```cfml
+component {
+ function init() { return this; }
+ function myMethod( required string arg1, string arg2="" ) {
+ var localVar = "value";
+ return localVar;
+ }
+}
+```
+
+### The `$` Prefix Convention
+Services marked `@presideService` get injected with helper methods prefixed `$`:
+```cfml
+$getPresideObject( "my_object" ) // get DAO
+$getPresideSetting( "cat", "key" ) // system settings
+$isFeatureEnabled( "featureName" ) // feature flags
+$hasAdminPermission( "perm.key" ) // permissions
+$audit( action="x", type="y" ) // audit trail
+$sendEmail( template="x", args={} ) // send email
+```
+
+### Config Inheritance
+```
+preside.system.config.Config ← base
+ └── /application/config/Config.cfc ← app overrides
+ └── /application/config/LocalConfig.cfc ← local dev
+```
+Environment variables override via `.env` (prefix `PRESIDE_`) or `/application/config/.injectedConfiguration`.
+
+### Reload URLs
+```
+/?fwreinit=true # Full reload
+/?fwReinitCaches=true # Clear caches only
+/?fwReinitDbSync=true # Sync DB + reload objects
+/?fwReinitForms=true # Reload form definitions
+/?fwReinitI18n=true # Reload i18n bundles
+```
+
+### WireBox Injection DSL
+```cfml
+property name="myService" inject="myService";
+property name="myObject" inject="presidecms:object:my_object";
+property name="cacheProv" inject="cachebox:MyCache";
+property name="setting" inject="coldbox:setting:myKey";
+property name="lazyService" inject="delayedInjector:someService";
+property name="featureService" inject="featureInjection:myFeature:MyService";
+```
+
+### i18n URI Format
+```
+bundle:key.path
+# e.g.:
+cms:sitetree.title
+preside-objects.blog_post:field.title.title
+system-config.email:tab.smtp.title
+```
+
+### Feature Flags
+```cfml
+// Define in Config.cfc
+settings.features.myFeature = { enabled=true, dependsOn=["admin"] };
+
+// Check anywhere
+if ( isFeatureEnabled( "myFeature" ) ) { ... } // handlers/views
+if ( $isFeatureEnabled( "myFeature" ) ) { ... } // services
+
+// Apply to objects/handlers/views
+/** @feature myFeature */
+component { ... }
+```