diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx
index cd7a933f46..a2f6b5e657 100644
--- a/content/docs/references/ui/action.mdx
+++ b/content/docs/references/ui/action.mdx
@@ -37,8 +37,8 @@ const result = Action.parse(data);
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'global_nav'>[]` | optional | Locations where this action is visible |
| **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override |
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api'>` | ✅ | Action functionality type |
-| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint |
-| **execute** | `string` | optional | Legacy execution logic |
+| **target** | `string` | conditional | URL, Script Name, Flow ID, Modal/Page Name, or API Endpoint. **Required** for `url`, `flow`, `modal`, and `api` types; recommended for `script`. |
+| **execute** | `string` | optional | ⚠️ **Deprecated** — Use `target` instead. Auto-migrated to `target` during parsing. Will be removed in a future version. |
| **params** | `Object[]` | optional | Input parameters required from user |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| **confirmText** | `string \| Object` | optional | Confirmation message before execution |
@@ -51,6 +51,51 @@ const result = Action.parse(data);
| **timeout** | `number` | optional | Maximum execution time in milliseconds for the action |
| **aria** | `Object` | optional | ARIA accessibility attributes |
+### Target Binding Rules
+
+The `target` field is the canonical way to bind an action to its handler:
+
+| Action Type | target | Description |
+| :--- | :--- | :--- |
+| `script` | Recommended | Function name to invoke (e.g. `completeTask`) |
+| `url` | **Required** | URL to navigate to |
+| `flow` | **Required** | Flow name to invoke (validated against defined flows) |
+| `modal` | **Required** | Page/modal name to open (validated against defined pages) |
+| `api` | **Required** | API endpoint to call |
+
+### Examples
+
+```typescript
+// Script action with handler target
+const action: Action = {
+ name: 'complete_task',
+ label: 'Mark Complete',
+ type: 'script',
+ target: 'completeTask', // ← references a registered handler function
+ locations: ['record_header'],
+ refreshAfter: true,
+};
+
+// Flow action
+const flowAction: Action = {
+ name: 'convert_lead',
+ label: 'Convert Lead',
+ type: 'flow',
+ target: 'lead_conversion', // ← must match a defined flow name
+};
+
+// Modal action
+const modalAction: Action = {
+ name: 'defer_task',
+ label: 'Defer Task',
+ type: 'modal',
+ target: 'defer_task_modal', // ← must match a defined page name
+};
+```
+
+
+**Migration Note:** The `execute` field is deprecated. If `execute` is provided without `target`, it is automatically migrated to `target` during schema parsing. Always use `target` in new code.
+
---
@@ -69,3 +114,25 @@ const result = Action.parse(data);
---
+## Cross-Reference Validation
+
+`defineStack()` validates action cross-references at build time:
+
+- **`type: 'flow'`** — `target` is checked against the `flows[]` collection (when flows are defined).
+- **`type: 'modal'`** — `target` is checked against the `pages[]` collection (when pages are defined).
+
+When the target collection is empty, validation is skipped because referenced items may come from plugins.
+
+---
+
+## Platform Comparison
+
+| Capability | ObjectStack | Salesforce | ServiceNow | Power Platform |
+| :--- | :--- | :--- | :--- | :--- |
+| **Declarative actions** | `ActionSchema` with `target` binding | Lightning Actions (Quick Actions) | UI Actions / Client Scripts | Power Fx `OnSelect` |
+| **Action types** | `script`, `url`, `modal`, `flow`, `api` | URL, Flow, LWC, Visualforce | Client Script, UI Policy, Flow | Navigate, Patch, Launch |
+| **Handler binding** | `target` string → `engine.registerAction()` | Apex Controller `@AuraEnabled` | Script Include + GlideAjax | Power Automate Cloud Flow |
+| **Cross-ref validation** | Build-time (`defineStack`) | Deploy-time (Metadata API) | Update Set validation | Solution Checker |
+| **Modal integration** | `type: 'modal'` + page name target | `lightning:overlayLibrary` | GlideModal / GlideDialogWindow | `Navigate(Screen)` |
+| **Bulk operations** | `bulkEnabled` + `locations: ['list_toolbar']` | List Button + Mass Quick Action | List v3 Actions | Gallery `OnSelect` multi |
+
diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts
index e6fadf1096..71e2c7417b 100644
--- a/examples/app-crm/objectstack.config.ts
+++ b/examples/app-crm/objectstack.config.ts
@@ -26,6 +26,20 @@ import {
RoleHierarchy,
} from './src/sharing';
+// ─── Action Handler Registration (runtime lifecycle) ────────────────
+// Handlers are wired separately from metadata. The `onEnable` export
+// is called by the kernel's AppPlugin after the engine is ready.
+// See: src/actions/register-handlers.ts for the full registration flow.
+import { registerCrmActionHandlers } from './src/actions/register-handlers';
+
+/**
+ * Plugin lifecycle hook — called by AppPlugin when the engine is ready.
+ * This is where action handlers are registered on the ObjectQL engine.
+ */
+export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => {
+ registerCrmActionHandlers(ctx.ql);
+};
+
export default defineStack({
manifest: {
id: 'com.example.crm',
diff --git a/examples/app-crm/src/actions/handlers/case.handlers.ts b/examples/app-crm/src/actions/handlers/case.handlers.ts
new file mode 100644
index 0000000000..a0d45d37fe
--- /dev/null
+++ b/examples/app-crm/src/actions/handlers/case.handlers.ts
@@ -0,0 +1,46 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Case Action Handlers
+ *
+ * Handler implementations for actions defined in case.actions.ts.
+ *
+ * @example Registration:
+ * ```ts
+ * engine.registerAction('case', 'escalateCase', escalateCase);
+ * engine.registerAction('case', 'closeCase', closeCase);
+ * ```
+ */
+
+interface ActionContext {
+ record: Record;
+ user: { id: string; name: string };
+ engine: {
+ update(object: string, id: string, data: Record): Promise;
+ };
+ params?: Record;
+}
+
+/** Escalate a case to the escalation team */
+export async function escalateCase(ctx: ActionContext): Promise {
+ const { record, engine, user, params } = ctx;
+ await engine.update('case', record._id as string, {
+ is_escalated: true,
+ escalation_reason: params?.reason as string,
+ escalated_by: user.id,
+ escalated_at: new Date().toISOString(),
+ priority: 'urgent',
+ });
+}
+
+/** Close a case with a resolution */
+export async function closeCase(ctx: ActionContext): Promise {
+ const { record, engine, user, params } = ctx;
+ await engine.update('case', record._id as string, {
+ is_closed: true,
+ resolution: params?.resolution as string,
+ closed_by: user.id,
+ closed_at: new Date().toISOString(),
+ status: 'closed',
+ });
+}
diff --git a/examples/app-crm/src/actions/handlers/contact.handlers.ts b/examples/app-crm/src/actions/handlers/contact.handlers.ts
new file mode 100644
index 0000000000..15918901e7
--- /dev/null
+++ b/examples/app-crm/src/actions/handlers/contact.handlers.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Contact Action Handlers
+ *
+ * Handler implementations for actions defined in contact.actions.ts.
+ *
+ * @example Registration:
+ * ```ts
+ * engine.registerAction('contact', 'markAsPrimaryContact', markAsPrimaryContact);
+ * engine.registerAction('contact', 'sendEmail', sendEmail);
+ * ```
+ */
+
+interface ActionContext {
+ record: Record;
+ user: { id: string; name: string };
+ engine: {
+ update(object: string, id: string, data: Record): Promise;
+ insert(object: string, data: Record): Promise<{ _id: string }>;
+ find(object: string, query: Record): Promise>>;
+ };
+ params?: Record;
+}
+
+/** Mark a contact as the primary contact for its account */
+export async function markAsPrimaryContact(ctx: ActionContext): Promise {
+ const { record, engine } = ctx;
+ const accountId = record.account_id as string;
+
+ // Clear existing primary contacts on the same account
+ const siblings = await engine.find('contact', { account_id: accountId, is_primary: true });
+ for (const sibling of siblings) {
+ await engine.update('contact', sibling._id as string, { is_primary: false });
+ }
+
+ // Set current contact as primary
+ await engine.update('contact', record._id as string, { is_primary: true });
+}
+
+/** Send an email to a contact (modal form submission handler) */
+export async function sendEmail(ctx: ActionContext): Promise<{ activityId: string }> {
+ const { record, engine, user, params } = ctx;
+ const activity = await engine.insert('activity', {
+ type: 'email',
+ subject: params?.subject ? String(params.subject) : `Email to ${record.email}`,
+ body: params?.body ? String(params.body) : '',
+ contact_id: record._id as string,
+ account_id: record.account_id as string,
+ direction: 'outbound',
+ status: 'sent',
+ created_by: user.id,
+ sent_at: new Date().toISOString(),
+ });
+ return { activityId: activity._id };
+}
diff --git a/examples/app-crm/src/actions/handlers/global.handlers.ts b/examples/app-crm/src/actions/handlers/global.handlers.ts
new file mode 100644
index 0000000000..a1ec40564b
--- /dev/null
+++ b/examples/app-crm/src/actions/handlers/global.handlers.ts
@@ -0,0 +1,53 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Global Action Handlers
+ *
+ * Handler implementations for cross-domain actions defined in global.actions.ts.
+ *
+ * @example Registration:
+ * ```ts
+ * engine.registerAction('*', 'exportToCSV', exportToCSV);
+ * engine.registerAction('*', 'logCall', logCall);
+ * ```
+ */
+
+interface ActionContext {
+ record: Record;
+ user: { id: string; name: string };
+ engine: {
+ insert(object: string, data: Record): Promise<{ _id: string }>;
+ find(object: string, query: Record): Promise>>;
+ };
+ params?: Record;
+}
+
+/** Export records of a given object to CSV format */
+export async function exportToCSV(ctx: ActionContext): Promise {
+ const { params, engine } = ctx;
+ const objectName = (params?.objectName ?? 'account') as string;
+ const records = await engine.find(objectName, {});
+ if (records.length === 0) return '';
+
+ const keys = Object.keys(records[0]);
+ const header = keys.join(',');
+ const rows = records.map((r) => keys.map((k) => r[k] ?? '').join(','));
+ return [header, ...rows].join('\n');
+}
+
+/** Log a phone call as an activity record (modal form submission handler) */
+export async function logCall(ctx: ActionContext): Promise<{ activityId: string }> {
+ const { record, engine, user, params } = ctx;
+ const activity = await engine.insert('activity', {
+ type: 'call',
+ subject: params?.subject ? String(params.subject) : 'Untitled Call',
+ duration_minutes: params?.duration ? Number(params.duration) : 0,
+ notes: params?.notes ? String(params.notes) : '',
+ related_to_id: record._id as string,
+ direction: 'outbound',
+ status: 'completed',
+ created_by: user.id,
+ call_date: new Date().toISOString(),
+ });
+ return { activityId: activity._id };
+}
diff --git a/examples/app-crm/src/actions/handlers/index.ts b/examples/app-crm/src/actions/handlers/index.ts
new file mode 100644
index 0000000000..bc8252885b
--- /dev/null
+++ b/examples/app-crm/src/actions/handlers/index.ts
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Action Handler Implementations Barrel
+ *
+ * Re-exports all handler functions for registration via engine.registerAction().
+ */
+export { convertLead, addToCampaign } from './lead.handlers';
+export { cloneRecord, massUpdateStage } from './opportunity.handlers';
+export { escalateCase, closeCase } from './case.handlers';
+export { markAsPrimaryContact, sendEmail } from './contact.handlers';
+export { exportToCSV, logCall } from './global.handlers';
diff --git a/examples/app-crm/src/actions/handlers/lead.handlers.ts b/examples/app-crm/src/actions/handlers/lead.handlers.ts
new file mode 100644
index 0000000000..4d426d0e4e
--- /dev/null
+++ b/examples/app-crm/src/actions/handlers/lead.handlers.ts
@@ -0,0 +1,87 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Lead Action Handlers
+ *
+ * Handler implementations for lead-domain actions defined in lead.actions.ts.
+ * The `ConvertLeadAction` (type: flow) is handled by the flow engine;
+ * `CreateCampaignAction` (type: modal) is handled by the UI modal system.
+ *
+ * This file provides the server-side logic backing these actions.
+ *
+ * @example Registration:
+ * ```ts
+ * engine.registerAction('lead', 'convertLead', convertLead);
+ * ```
+ */
+
+interface ActionContext {
+ record: Record;
+ user: { id: string; name: string };
+ engine: {
+ update(object: string, id: string, data: Record): Promise;
+ insert(object: string, data: Record): Promise<{ _id: string }>;
+ find(object: string, query: Record): Promise>>;
+ };
+ params?: Record;
+}
+
+/** Convert a qualified lead into Account, Contact, and Opportunity records */
+export async function convertLead(ctx: ActionContext): Promise<{
+ accountId: string;
+ contactId: string;
+ opportunityId: string;
+}> {
+ const { record, engine, user } = ctx;
+
+ const account = await engine.insert('account', {
+ name: record.company as string,
+ website: record.website,
+ industry: record.industry,
+ created_by: user.id,
+ });
+
+ const contact = await engine.insert('contact', {
+ first_name: record.first_name,
+ last_name: record.last_name,
+ email: record.email,
+ phone: record.phone,
+ account_id: account._id,
+ });
+
+ const opportunity = await engine.insert('opportunity', {
+ name: `${record.company} - New Opportunity`,
+ account_id: account._id,
+ contact_id: contact._id,
+ stage: 'prospecting',
+ amount: record.estimated_value ?? 0,
+ });
+
+ await engine.update('lead', record._id as string, {
+ is_converted: true,
+ status: 'converted',
+ converted_account_id: account._id,
+ converted_contact_id: contact._id,
+ converted_opportunity_id: opportunity._id,
+ });
+
+ return {
+ accountId: account._id,
+ contactId: contact._id,
+ opportunityId: opportunity._id,
+ };
+}
+
+/** Add selected leads to a campaign */
+export async function addToCampaign(ctx: ActionContext): Promise {
+ const { params, engine } = ctx;
+ const campaignId = params?.campaign as string;
+ const leadIds = (params?.selectedIds ?? []) as string[];
+ for (const leadId of leadIds) {
+ await engine.insert('campaign_member', {
+ campaign_id: campaignId,
+ lead_id: leadId,
+ status: 'sent',
+ });
+ }
+}
diff --git a/examples/app-crm/src/actions/handlers/opportunity.handlers.ts b/examples/app-crm/src/actions/handlers/opportunity.handlers.ts
new file mode 100644
index 0000000000..49d4abdc1e
--- /dev/null
+++ b/examples/app-crm/src/actions/handlers/opportunity.handlers.ts
@@ -0,0 +1,44 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Opportunity Action Handlers
+ *
+ * Handler implementations for actions defined in opportunity.actions.ts.
+ *
+ * @example Registration:
+ * ```ts
+ * engine.registerAction('opportunity', 'cloneRecord', cloneRecord);
+ * ```
+ */
+
+interface ActionContext {
+ record: Record;
+ user: { id: string; name: string };
+ engine: {
+ update(object: string, id: string, data: Record): Promise;
+ insert(object: string, data: Record): Promise<{ _id: string }>;
+ find(object: string, query: Record): Promise>>;
+ };
+ params?: Record;
+}
+
+/** Clone an opportunity record */
+export async function cloneRecord(ctx: ActionContext): Promise<{ _id: string }> {
+ const { record, engine } = ctx;
+ const { _id, created_at, updated_at, ...fields } = record as Record;
+ return engine.insert('opportunity', {
+ ...fields,
+ name: `Copy of ${fields.name ?? 'Untitled'}`,
+ stage: 'prospecting',
+ });
+}
+
+/** Mass update opportunity stage for selected records */
+export async function massUpdateStage(ctx: ActionContext): Promise {
+ const { params, engine } = ctx;
+ const newStage = params?.stage as string;
+ const ids = (params?.selectedIds ?? []) as string[];
+ for (const id of ids) {
+ await engine.update('opportunity', id, { stage: newStage });
+ }
+}
diff --git a/examples/app-crm/src/actions/index.ts b/examples/app-crm/src/actions/index.ts
index 39d4d3b345..d614d6977e 100644
--- a/examples/app-crm/src/actions/index.ts
+++ b/examples/app-crm/src/actions/index.ts
@@ -2,6 +2,12 @@
/**
* Action Definitions Barrel
+ *
+ * Exports action metadata definitions only. Used by `Object.values()` in
+ * objectstack.config.ts to auto-collect all action declarations for defineStack().
+ *
+ * **Handler functions** are exported from `./handlers/` — see register-handlers.ts
+ * for the complete registration flow.
*/
export { EscalateCaseAction, CloseCaseAction } from './case.actions';
export { MarkPrimaryContactAction, SendEmailAction } from './contact.actions';
diff --git a/examples/app-crm/src/actions/register-handlers.ts b/examples/app-crm/src/actions/register-handlers.ts
new file mode 100644
index 0000000000..5787c385d8
--- /dev/null
+++ b/examples/app-crm/src/actions/register-handlers.ts
@@ -0,0 +1,107 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Action Handler Registration — Enterprise CRM
+ *
+ * Demonstrates the complete lifecycle for wiring action handler functions
+ * to their declarative action definitions across all CRM domains.
+ *
+ * ## Architecture
+ *
+ * ```
+ * ┌──────────────────────┐
+ * │ objectstack.config │ defineStack() — declarative metadata only
+ * │ ├─ actions[] │ Action definitions with `target` strings
+ * │ ├─ objects[] │ Object schemas
+ * │ └─ flows[] │ Flow definitions
+ * └──────────┬───────────┘
+ * │ AppPlugin.start()
+ * ▼
+ * ┌──────────────────────┐
+ * │ onEnable(ctx) │ Plugin lifecycle hook
+ * │ └─ ctx.ql │ ObjectQL engine reference
+ * └──────────┬───────────┘
+ * │ registerCrmActionHandlers(ctx.ql)
+ * ▼
+ * ┌──────────────────────┐
+ * │ engine.registerAction │ Binds target → handler function
+ * │ ('lead', │
+ * │ 'convertLead', │ ← matches action target string
+ * │ convertLead) │ ← actual function implementation
+ * └──────────────────────┘
+ * ```
+ *
+ * ## Action Type → Handler Mapping
+ *
+ * | Action Type | Handler Location | Registration |
+ * |-------------|-----------------|--------------|
+ * | `script` | `*.handlers.ts` | `engine.registerAction()` |
+ * | `modal` | `*.handlers.ts` | `engine.registerAction()` — processes modal form |
+ * | `flow` | Flow engine | Auto-resolved via `flows[]` definition |
+ * | `url` | Browser/UI | No server handler needed |
+ * | `api` | API routes | Auto-resolved via `apis[]` definition |
+ *
+ * @see handlers/ — Handler function implementations per domain
+ */
+
+import {
+ convertLead,
+ addToCampaign,
+ cloneRecord,
+ massUpdateStage,
+ escalateCase,
+ closeCase,
+ markAsPrimaryContact,
+ sendEmail,
+ exportToCSV,
+ logCall,
+} from './handlers';
+
+/**
+ * Register all CRM action handlers on the ObjectQL engine.
+ *
+ * @param engine - The ObjectQL engine instance
+ *
+ * @example Usage in plugin lifecycle:
+ * ```ts
+ * import { registerCrmActionHandlers } from './src/actions/register-handlers';
+ *
+ * export const onEnable = async (ctx: { ql: ObjectQL }) => {
+ * registerCrmActionHandlers(ctx.ql);
+ * };
+ * ```
+ */
+export function registerCrmActionHandlers(engine: {
+ registerAction(objectName: string, actionName: string, handler: (...args: unknown[]) => unknown): void;
+}): void {
+ // ─── Lead Domain ───────────────────────────────────────────────────
+ // ConvertLeadAction (type: flow) — also has server-side handler for
+ // programmatic conversion outside the screen flow.
+ engine.registerAction('lead', 'convertLead', convertLead);
+ // CreateCampaignAction (type: modal) — processes campaign assignment form
+ engine.registerAction('lead', 'addToCampaign', addToCampaign);
+
+ // ─── Opportunity Domain ────────────────────────────────────────────
+ // CloneOpportunityAction (type: script)
+ engine.registerAction('opportunity', 'cloneRecord', cloneRecord);
+ // MassUpdateStageAction (type: modal) — processes stage selection form
+ engine.registerAction('opportunity', 'massUpdateStage', massUpdateStage);
+
+ // ─── Case Domain ───────────────────────────────────────────────────
+ // EscalateCaseAction (type: modal) — processes escalation reason form
+ engine.registerAction('case', 'escalateCase', escalateCase);
+ // CloseCaseAction (type: modal) — processes resolution form
+ engine.registerAction('case', 'closeCase', closeCase);
+
+ // ─── Contact Domain ────────────────────────────────────────────────
+ // MarkPrimaryContactAction (type: script)
+ engine.registerAction('contact', 'markAsPrimaryContact', markAsPrimaryContact);
+ // SendEmailAction (type: modal) — processes email composer form
+ engine.registerAction('contact', 'sendEmail', sendEmail);
+
+ // ─── Global (cross-domain) ─────────────────────────────────────────
+ // ExportToCsvAction (type: script) — wildcard '*' applies to all objects
+ engine.registerAction('*', 'exportToCSV', exportToCSV);
+ // LogCallAction (type: modal) — processes call log form
+ engine.registerAction('*', 'logCall', logCall);
+}
diff --git a/examples/app-todo/objectstack.config.ts b/examples/app-todo/objectstack.config.ts
index f64a4c9f70..a7608f6bd8 100644
--- a/examples/app-todo/objectstack.config.ts
+++ b/examples/app-todo/objectstack.config.ts
@@ -11,6 +11,20 @@ import * as flows from './src/flows';
import * as apps from './src/apps';
import * as translations from './src/translations';
+// ─── Action Handler Registration (runtime lifecycle) ────────────────
+// Handlers are wired separately from metadata. The `onEnable` export
+// is called by the kernel's AppPlugin after the engine is ready.
+// See: src/actions/register-handlers.ts for the full registration flow.
+import { registerTaskActionHandlers } from './src/actions/register-handlers';
+
+/**
+ * Plugin lifecycle hook — called by AppPlugin when the engine is ready.
+ * This is where action handlers are registered on the ObjectQL engine.
+ */
+export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => {
+ registerTaskActionHandlers(ctx.ql);
+};
+
export default defineStack({
manifest: {
id: 'com.example.todo',
diff --git a/examples/app-todo/src/actions/index.ts b/examples/app-todo/src/actions/index.ts
index 532733b7a6..803bc9f29d 100644
--- a/examples/app-todo/src/actions/index.ts
+++ b/examples/app-todo/src/actions/index.ts
@@ -2,6 +2,12 @@
/**
* Action Definitions Barrel
+ *
+ * Exports action metadata definitions only. Used by `Object.values()` in
+ * objectstack.config.ts to auto-collect all action declarations for defineStack().
+ *
+ * **Handler functions** are exported from `./handlers/` — see register-handlers.ts
+ * for the complete registration flow.
*/
export {
CompleteTaskAction,
diff --git a/examples/app-todo/src/actions/register-handlers.ts b/examples/app-todo/src/actions/register-handlers.ts
new file mode 100644
index 0000000000..4338d2f3ab
--- /dev/null
+++ b/examples/app-todo/src/actions/register-handlers.ts
@@ -0,0 +1,96 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Action Handler Registration — Todo App
+ *
+ * Demonstrates the complete lifecycle for wiring action handler functions
+ * to their declarative action definitions. This is the bridge between:
+ *
+ * 1. **Action Definitions** (task.actions.ts) — declarative metadata with
+ * `target` string references (e.g., `target: 'completeTask'`).
+ *
+ * 2. **Handler Functions** (task.handlers.ts) — actual implementations that
+ * execute business logic (e.g., `async function completeTask(ctx) {...}`).
+ *
+ * 3. **Registration** (this file) — binds handler functions to the engine so
+ * the runtime can resolve `target` strings to executable code.
+ *
+ * ## How It Works
+ *
+ * ObjectStack separates action *declaration* (metadata) from action *execution*
+ * (handler). The `defineStack()` config only includes declarative metadata.
+ * Handlers are registered at runtime via the plugin lifecycle:
+ *
+ * ```
+ * ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐
+ * │ task.actions.ts │ │ task.handlers.ts │ │ register-handlers.ts │
+ * │ (metadata) │ │ (implementation) │ │ (wiring / lifecycle) │
+ * │ │ │ │ │ │
+ * │ target: │───▶│ export function │───▶│ engine.registerAction │
+ * │ 'completeTask' │ │ completeTask() │ │ ('task','completeTask',│
+ * │ │ │ │ │ completeTask) │
+ * └─────────────────┘ └──────────────────┘ └────────────────────────┘
+ * ```
+ *
+ * ## When Does Registration Happen?
+ *
+ * In the ObjectStack kernel lifecycle, the AppPlugin calls `onEnable(ctx)`
+ * on each app bundle after the engine is ready. This is where you register
+ * action handlers:
+ *
+ * ```ts
+ * // objectstack.config.ts (runtime integration)
+ * export const onEnable = (ctx) => registerTaskActionHandlers(ctx.ql);
+ * ```
+ *
+ * @see task.actions.ts — Action metadata definitions
+ * @see task.handlers.ts — Handler function implementations
+ */
+
+import {
+ completeTask,
+ startTask,
+ cloneTask,
+ deferTask,
+ setReminder,
+ massCompleteTasks,
+ deleteCompletedTasks,
+ exportTasksToCSV,
+} from './task.handlers';
+
+/**
+ * Register all task action handlers on the ObjectQL engine.
+ *
+ * Called during the plugin `onEnable` lifecycle phase when the kernel
+ * makes the data engine available to the app.
+ *
+ * @param engine - The ObjectQL engine instance (from `ctx.ql`)
+ *
+ * @example Usage in plugin lifecycle:
+ * ```ts
+ * import { registerTaskActionHandlers } from './src/actions/register-handlers';
+ *
+ * export const onEnable = async (ctx: { ql: ObjectQL }) => {
+ * registerTaskActionHandlers(ctx.ql);
+ * };
+ * ```
+ */
+export function registerTaskActionHandlers(engine: {
+ registerAction(objectName: string, actionName: string, handler: (...args: unknown[]) => unknown): void;
+}): void {
+ // ─── Script-type actions (server-side handlers) ────────────────────
+ engine.registerAction('task', 'completeTask', completeTask);
+ engine.registerAction('task', 'startTask', startTask);
+ engine.registerAction('task', 'cloneTask', cloneTask);
+ engine.registerAction('task', 'massCompleteTasks', massCompleteTasks);
+ engine.registerAction('task', 'deleteCompletedTasks', deleteCompletedTasks);
+ engine.registerAction('task', 'exportTasksToCSV', exportTasksToCSV);
+
+ // ─── Modal-type actions (server-side form submission handlers) ─────
+ // These process the params collected by the modal UI before the
+ // engine updates the record. The modal target (e.g. 'defer_task_modal')
+ // tells the UI which modal page to open; the handler below processes
+ // the submitted form data.
+ engine.registerAction('task', 'deferTask', deferTask);
+ engine.registerAction('task', 'setReminder', setReminder);
+}
diff --git a/examples/app-todo/src/actions/task.handlers.ts b/examples/app-todo/src/actions/task.handlers.ts
new file mode 100644
index 0000000000..771cee766b
--- /dev/null
+++ b/examples/app-todo/src/actions/task.handlers.ts
@@ -0,0 +1,116 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Task Action Handlers
+ *
+ * Example handler implementations for actions defined in task.actions.ts.
+ * Each handler is registered via `engine.registerAction()` and referenced
+ * by name through the action's `target` field.
+ *
+ * @example Registration (in a plugin or config bootstrap):
+ * ```ts
+ * engine.registerAction('task', 'completeTask', completeTask);
+ * engine.registerAction('task', 'startTask', startTask);
+ * ```
+ */
+
+// ─── Handler Context (simplified for example purposes) ──────────────
+interface ActionContext {
+ /** The record being acted upon */
+ record: Record;
+ /** Current authenticated user */
+ user: { id: string; name: string };
+ /** Data engine for CRUD operations */
+ engine: {
+ update(object: string, id: string, data: Record): Promise;
+ insert(object: string, data: Record): Promise<{ _id: string }>;
+ find(object: string, query: Record): Promise>>;
+ delete(object: string, ids: string[]): Promise;
+ };
+ /** Action parameters (from user input / params) */
+ params?: Record;
+}
+
+/** Mark a single task as complete */
+export async function completeTask(ctx: ActionContext): Promise {
+ const { record, engine, user } = ctx;
+ await engine.update('task', record._id as string, {
+ status: 'completed',
+ completed_at: new Date().toISOString(),
+ completed_by: user.id,
+ });
+}
+
+/** Mark a task as in-progress */
+export async function startTask(ctx: ActionContext): Promise {
+ const { record, engine } = ctx;
+ await engine.update('task', record._id as string, {
+ status: 'in_progress',
+ started_at: new Date().toISOString(),
+ });
+}
+
+/** Clone a task (duplicate with reset status) */
+export async function cloneTask(ctx: ActionContext): Promise<{ _id: string }> {
+ const { record, engine } = ctx;
+ const { _id, created_at, updated_at, completed_at, completed_by, ...fields } = record as Record;
+ return engine.insert('task', {
+ ...fields,
+ status: 'not_started',
+ subject: `Copy of ${fields.subject ?? 'Untitled'}`,
+ });
+}
+
+/** Mark all selected tasks as complete (bulk) */
+export async function massCompleteTasks(ctx: ActionContext): Promise {
+ const { params, engine, user } = ctx;
+ const ids = (params?.selectedIds ?? []) as string[];
+ const now = new Date().toISOString();
+ for (const id of ids) {
+ await engine.update('task', id, {
+ status: 'completed',
+ completed_at: now,
+ completed_by: user.id,
+ });
+ }
+}
+
+/** Delete all completed tasks */
+export async function deleteCompletedTasks(ctx: ActionContext): Promise {
+ const { engine } = ctx;
+ const completed = await engine.find('task', { status: 'completed' });
+ const ids = completed.map((r) => r._id as string);
+ if (ids.length > 0) {
+ await engine.delete('task', ids);
+ }
+}
+
+/** Defer a task by updating its due date (modal form submission handler) */
+export async function deferTask(ctx: ActionContext): Promise {
+ const { record, engine, params } = ctx;
+ await engine.update('task', record._id as string, {
+ due_date: params?.new_due_date ? String(params.new_due_date) : null,
+ defer_reason: params?.reason ? String(params.reason) : null,
+ status: 'waiting',
+ });
+}
+
+/** Set a reminder on a task (modal form submission handler) */
+export async function setReminder(ctx: ActionContext): Promise {
+ const { record, engine, params } = ctx;
+ await engine.update('task', record._id as string, {
+ reminder_date: params?.reminder_date ? String(params.reminder_date) : null,
+ has_reminder: true,
+ });
+}
+
+/** Export tasks to CSV format */
+export async function exportTasksToCSV(ctx: ActionContext): Promise {
+ const { engine } = ctx;
+ const tasks = await engine.find('task', {});
+ const header = 'subject,status,priority,category,due_date';
+ const rows = tasks.map((t) =>
+ [t.subject, t.status, t.priority, t.category, t.due_date ?? ''].join(','),
+ );
+ return [header, ...rows].join('\n');
+}
diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts
index 0c59772781..de2ff7bb60 100644
--- a/packages/spec/src/stack.test.ts
+++ b/packages/spec/src/stack.test.ts
@@ -1038,9 +1038,69 @@ describe('defineStack - Action Cross-Reference Validation', () => {
});
// ============================================================================
-// Example-Level Strict Validation — mirrors examples/app-todo & examples/app-crm
+// Action → Modal Cross-Reference Validation — ensures modal targets resolve to pages
// ============================================================================
+describe('defineStack - Modal Cross-Reference Validation', () => {
+ const baseManifest = {
+ id: 'com.example.test',
+ name: 'test-project',
+ version: '1.0.0',
+ type: 'app' as const,
+ };
+
+ const makePage = (name: string) => ({
+ name,
+ label: name,
+ regions: [{ name: 'main', components: [] }],
+ });
+
+ it('should detect modal action referencing undefined page', () => {
+ const config = {
+ manifest: baseManifest,
+ objects: [
+ { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } },
+ ],
+ pages: [makePage('existing_page')],
+ actions: [
+ { name: 'open_modal', label: 'Open Modal', type: 'modal' as const, target: 'nonexistent_page' },
+ ],
+ };
+
+ expect(() => defineStack(config)).toThrow('cross-reference validation failed');
+ expect(() => defineStack(config)).toThrow('nonexistent_page');
+ });
+
+ it('should pass when modal action references a defined page', () => {
+ const config = {
+ manifest: baseManifest,
+ objects: [
+ { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } },
+ ],
+ pages: [makePage('defer_task_modal')],
+ actions: [
+ { name: 'defer_task', label: 'Defer Task', type: 'modal' as const, target: 'defer_task_modal' },
+ ],
+ };
+
+ expect(() => defineStack(config)).not.toThrow();
+ });
+
+ it('should skip modal validation when no pages are defined', () => {
+ const config = {
+ manifest: baseManifest,
+ objects: [
+ { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } },
+ ],
+ actions: [
+ { name: 'open_modal', label: 'Open Modal', type: 'modal' as const, target: 'some_modal' },
+ ],
+ };
+
+ expect(() => defineStack(config)).not.toThrow();
+ });
+});
+
describe('defineStack - Example-Level Strict Validation', () => {
it('should validate a Todo-style app config (strict mode)', () => {
const todoConfig = {
diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts
index ccbaa5febd..75ef8ec454 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -400,9 +400,9 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
}
}
- // Validate action → flow/target cross-references
- // Note: When no flows are defined (flowNames.size === 0), flow-type action targets
- // are not validated because the referenced flow may be provided by a plugin.
+ // Validate action → flow/modal cross-references
+ // Note: When no flows/pages are defined (size === 0), targets are not validated
+ // because the referenced items may be provided by a plugin.
// This is consistent with dashboard/page/report validation in navigation.
if (config.actions) {
const flowNames = new Set();
@@ -412,6 +412,13 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
}
}
+ const pageNames = new Set();
+ if (config.pages) {
+ for (const page of config.pages) {
+ pageNames.add(page.name);
+ }
+ }
+
for (const action of config.actions) {
// Validate flow-type actions reference a defined flow
if (action.type === 'flow' && action.target && flowNames.size > 0 && !flowNames.has(action.target)) {
@@ -419,6 +426,13 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
`Action '${action.name}' references flow '${action.target}' which is not defined in flows.`,
);
}
+
+ // Validate modal-type actions reference a defined page
+ if (action.type === 'modal' && action.target && pageNames.size > 0 && !pageNames.has(action.target)) {
+ errors.push(
+ `Action '${action.name}' references page '${action.target}' (via modal target) which is not defined in pages.`,
+ );
+ }
}
}