Found while implementing ADR-0110 (#3958). The D5 reconciliation flagged real orphans in a shipped example on its very first run, and tracing why exposed a contradiction between the client and the framework about whether a modal action has a server half.
Pre-existing and not a regression from #3958 — these handlers were already unreachable through the Console. Filing rather than widening that PR's scope (Prime Directive #10).
✅ RESOLVED — option 2 (modal stays client-only)
Fixed by objectstack#3974 (c5c78bb) and objectui#2973 (a136322).
Why option 2. The spec's own wording settles it — a modal action's target is "the modal/page name to open", i.e. a thing to render, and SERVER_DISPATCHED_ACTION_TYPES deliberately excludes it. actions.mdx had already documented the correct alternative in its body-belongs-to-script callout: "To collect input and run logic, use type: 'script' with params — the same dialog is collected, then the body runs with those values." The client's fallthrough was the outlier, not the framework's 400.
What changed.
- objectui —
modalActionHandler no longer falls through to serverActionHandler. An unresolvable target is reported as the authoring mistake it is, naming the action, the dud target, and the way out. The superseded test (which asserted the fallthrough POSTed /actions) now asserts the refusal.
- framework —
app-todo's defer_task / set_reminder are type: 'script' with target pointing at their real handler keys. They already declared exactly the params their handlers read (new_due_date / reason, reminder_date), so they were always "collect input, then run server-side" — only the type was wrong. deferTask and setReminder now execute for the first time.
- docs — the action-type table said
modal meant "collect input, then submit to a handler", contradicting the same page's REST table (modal → 400, "nothing for the server to run"). That inconsistency is very likely why the example was written the way it was; corrected to point at script + params.
Reconciliation after the fix, using the same key derivation dispatch uses:
registered: cloneTask, completeTask, deferTask, deleteCompletedTasks,
exportTasksToCSV, massCompleteTasks, setReminder, startTask
UNDECLARED handlers: none
script declarations with NO handler: none
Clean in both directions — the example no longer trips the [action-governance] boot warning it was about to start emitting, so nothing scaffolded from it inherits the broken shape.
Both Dogfood Regression Gate shards passed on the framework side, which is the meaningful confirmation here: that gate exercises app-todo directly.
Original report
The contradiction
objectui said a modal action falls through to its server handler. packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:
/**
* `type: 'modal'` dispatch. The action's `target` names the page to open
* (spec: "the modal/page name to open"), so try to render it client-side
* first. When it names neither a page nor an object there is nothing to
* render — the modal was the param dialog the runner already collected — so
* complete the action through its server-side handler, which is how a modal
* action bound to `engine.registerAction(...)` (or an inline `body`) still
* runs.
*/
const modalActionHandler = useCallback(async (action, context) => {
const schema = action.modal ?? action.target ?? action.params?.schema;
const descriptor = schema != null ? await resolveModalTarget(schema) : null;
if (descriptor) return modalHandler(descriptor);
return serverActionHandler(action, context); // ← the documented fallthrough
}, [...]);
The framework said it has none. packages/runtime/src/action-execution.ts:
export function headlessActionTypeError(deps, action, objectName) {
const type = action?.type ?? 'script';
if (SERVER_DISPATCHED_ACTION_TYPES.has(type)) return null;
...
return `Action '${name}'${on} is \`type: '${type}'\` — a client-side action with no server dispatch. `
+ `The renderer opens its \`target\`; there is nothing for the server to run.`;
}
modal is not in SERVER_DISPATCHED_ACTION_TYPES, so the fallthrough landed on a 400 — the one path objectui documented as "how a modal action bound to engine.registerAction(...) still runs" was the exact path the server refused.
What it already cost: two dead handlers in examples/app-todo
| Handler key |
Declaration |
Its target |
deferTask |
defer_task (type: 'modal') |
defer_task_modal |
setReminder |
set_reminder (type: 'modal') |
set_reminder_modal |
task.handlers.ts exported real bodies for both. Nothing could invoke them — through the Console the modal either rendered (server never called) or fell through to a 400; over REST /actions/todo_task/defer_task resolved the declaration → type: 'modal' → 400, and /actions/todo_task/deferTask found no declaration → 404 since ADR-0110 D3.
Found while implementing ADR-0110 (#3958). The D5 reconciliation flagged real orphans in a shipped example on its very first run, and tracing why exposed a contradiction between the client and the framework about whether a
modalaction has a server half.Pre-existing and not a regression from #3958 — these handlers were already unreachable through the Console. Filing rather than widening that PR's scope (Prime Directive #10).
✅ RESOLVED — option 2 (
modalstays client-only)Fixed by objectstack#3974 (
c5c78bb) and objectui#2973 (a136322).Why option 2. The spec's own wording settles it — a
modalaction'stargetis "the modal/page name to open", i.e. a thing to render, andSERVER_DISPATCHED_ACTION_TYPESdeliberately excludes it.actions.mdxhad already documented the correct alternative in itsbody-belongs-to-scriptcallout: "To collect input and run logic, usetype: 'script'withparams— the same dialog is collected, then the body runs with those values." The client's fallthrough was the outlier, not the framework's 400.What changed.
modalActionHandlerno longer falls through toserverActionHandler. An unresolvable target is reported as the authoring mistake it is, naming the action, the dud target, and the way out. The superseded test (which asserted the fallthrough POSTed/actions) now asserts the refusal.app-todo'sdefer_task/set_reminderaretype: 'script'withtargetpointing at their real handler keys. They already declared exactly theparamstheir handlers read (new_due_date/reason,reminder_date), so they were always "collect input, then run server-side" — only the type was wrong.deferTaskandsetRemindernow execute for the first time.modalmeant "collect input, then submit to a handler", contradicting the same page's REST table (modal→ 400, "nothing for the server to run"). That inconsistency is very likely why the example was written the way it was; corrected to point atscript+params.Reconciliation after the fix, using the same key derivation dispatch uses:
Clean in both directions — the example no longer trips the
[action-governance]boot warning it was about to start emitting, so nothing scaffolded from it inherits the broken shape.Both Dogfood Regression Gate shards passed on the framework side, which is the meaningful confirmation here: that gate exercises
app-tododirectly.Original report
The contradiction
objectui said a modal action falls through to its server handler.
packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:The framework said it has none.
packages/runtime/src/action-execution.ts:modalis not inSERVER_DISPATCHED_ACTION_TYPES, so the fallthrough landed on a 400 — the one path objectui documented as "how a modal action bound toengine.registerAction(...)still runs" was the exact path the server refused.What it already cost: two dead handlers in
examples/app-todotargetdeferTaskdefer_task(type: 'modal')defer_task_modalsetReminderset_reminder(type: 'modal')set_reminder_modaltask.handlers.tsexported real bodies for both. Nothing could invoke them — through the Console the modal either rendered (server never called) or fell through to a 400; over REST/actions/todo_task/defer_taskresolved the declaration →type: 'modal'→ 400, and/actions/todo_task/deferTaskfound no declaration → 404 since ADR-0110 D3.