diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl
index 34eb9618fa..6c5d2ad1f3 100644
--- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl
+++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl
@@ -510,3 +510,6 @@
{"area": "mdl/executor", "date": "2026-09-04", "symptom": "`return` inside a `loop` passes `mxcli check` AND `exec`, then mxbuild fails **CE0068** \"End events cannot be placed inside a loop.\" \u2014 even though MDL062 exists precisely to catch that. It fires only when the microflow header has NO `returns T as $Var` clause.", "cause": "MDL062 stood down for the whole microflow whenever the AS clause was present, on two claims: that buildFlowGraph synthesizes the End event from the variable so none lands in the loop, and that the shape builds CE0109 instead. `describe microflow` shows the in-loop `return` written either way, so the first was never true. The second was a measurement artefact: mxbuild reports ONE error per microflow, and in the shape that was measured the AS variable was never assigned, so CE0109 \"Undefined variable\" won the race and hid CE0068 underneath. Adding `declare $Done Boolean = false` and changing nothing else turns the same microflow from CE0109 into CE0068. The exemption was deleted, not narrowed.", "file": "`mdl/executor/validate_microflow_ce_gaps.go` (checkReturnInLoop \u2014 the `v.returnType.Variable != \"\"` early return); test inverted in `validate_microflow_ce_gaps_test.go` (TestMDL062_ExemptsReturnsAsClause -> TestMDL062_FiresWithReturnsAsClause); examples `mdl-examples/bug-tests/captrack-19-return-in-loop-as-clause{,.fail}.mdl`", "insight": "A second error in the same document can HIDE the one you are measuring, because mxbuild reports one error per microflow. An exemption justified by \"measured: builds X instead\" is only sound if the reproduction was otherwise valid \u2014 here the repro was broken in a second way, and the error that surfaced was the one nobody was asking about. When a measurement says a construct is clean, add the minimum that removes every OTHER error from that document and measure again; the differential (CE0109 -> CE0068 on one added `declare`) is what settles it. Reported as CapTrackV2 FINDINGS \u00a719."}
{"area": "mdl/executor", "date": "2026-09-04", "symptom": "`dataview dv (\u2026, OnClick: SHOW_PAGE \u2026)` parses, `mxcli check` is clean, `exec` writes the page without a word \u2014 and the rendered element has no handler and no role=\"button\". The same silence on `dynamictext`, `listview` and every other widget except a container or a button.", "cause": "`OnClick:` is an ALIAS for `Action:` (both stored as Properties[\"Action\"], #603), and mxcli writes that property for three widget kinds only: container/customcontainer, the buttons, and a navigationlist item. Every other widget drops it, and the property allow-lists behind MDL-WIDGET01/07 could not see that because they are widget-type AGNOSTIC \u2014 the same blind spot as #928's `editable:`. Added MDL-WIDGET23 (warning), with two messages: Mendix models no click action at all (dataview, dynamictext, inputs, groupbox\u2026) vs Mendix models one that mxcli cannot write (listview, staticimage, dynamicimage \u2014 measured against generated/metamodel).", "file": "`mdl/executor/validate_widget_onclick.go` (new), wired in `validate_widgets.go` beside validateWidgetEditability; `.claude/skills/mendix/create-page/reference/widgets.md`; example `mdl-examples/bug-tests/captrack-21-dataview-onclick-dropped.mdl`", "insight": "The first draft reported everything OUTSIDE an allow-list of the three writers, and running it over the shipped examples flagged three of them. Cause: `mxcli check` without `-p` has no widget registry, so `lookupWidgetDef` returns nil for a PLUGGABLE widget too and the caller's \"static widgets only\" branch silently does not hold \u2014 `datagrid` is DataGrid 2, a pluggable widget whose onClick the engine does write. For any rule keyed on widget type, an allow-list makes the unknown case an ERROR and a deny-list makes it silence; pick the deny-list, because a missed warning costs nothing and a false one tells an author their working page is broken. Running a new rule across mdl-examples/ before wiring it up is what caught it \u2014 the same exercise #893 describes. Reported as CapTrackV2 FINDINGS \u00a721."}
{"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ALTER SETTINGS MODEL AfterStartupMicroflow = 'Mod.MF_Seed'` accepts a microflow with no return type, `mxcli check` passes, and the build fails **CE0142** \"After startup microflow should return a boolean\". `ALTER MICROFLOW \u2026 RETURNS \u2026` does not parse either, so the remedy is DROP + CREATE.", "cause": "#274 made ALTER SETTINGS resolve the qualified names it writes, which catches a MISSPELLED microflow. Here the name resolves perfectly \u2014 the constraint is on the thing the setting names, not on the reference, and nothing looked at the return type. Added MDL073: a project-less pass (ValidateAfterStartupReturnType) for a microflow the script itself creates, which is the usual shape, plus the stored return type on the project path. Both call one function so they cannot drift. flowSignature gained ReturnKind because its existing Returns field is the entity name and cannot tell Boolean from void.", "file": "`mdl/executor/validate_settings_refs.go` (checkAfterStartupReturnsBoolean + ValidateAfterStartupReturnType), `validate_program.go`, `helpers.go` (buildMicroflowReturnTypes), `validate_datasource_args.go` (flowSignature.ReturnKind); examples `mdl-examples/bug-tests/captrack-6-after-startup-must-return-boolean{,.fail}.mdl`", "insight": "\"The reference resolves\" and \"the reference is usable\" are different questions, and a resolver answers only the first. Whenever a setting stores a NAME, ask what the platform requires of the named thing \u2014 an existence check will pass and the build will still fail, and the error arrives with no connection to the statement that caused it. The scope discipline that goes with it: only AfterStartup is type-checked, because only its rule was measured; BeforeShutdown and HealthCheck are left alone rather than constrained on a guess, and there is a control test asserting that. Reported as CapTrackV2 FINDINGS \u00a76."}
+{"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ACTIONBUTTON \u2026 (Action: SIGN_OUT)` is refused by the default engine \u2014 \"client action *pages.SignOutClientAction not yet supported by the modelsdk engine \u2014 rerun with MXCLI_ENGINE=legacy\" \u2014 and the suggested workaround SILENTLY produces a dead button: on legacy the action is written as `Forms$NoAction`, so it renders, says \"Sign out\", and does nothing, with `mxcli check`, `exec` and `mx check` all clean.", "cause": "Neither engine had a case for the action. modelsdk's clientActionToGen ended in a loud default; sdk/mpr's serializeClientAction ended in a QUIET one that returns Forms$NoAction for anything unmatched. Added the case to both. The document is two keys \u2014 `Forms$SignOutClientAction` + `DisabledDuringExecution: true` \u2014 pinned against a Studio Pro-authored button in ako/TestApp, plus `sign_out` in the DESCRIBE renderer so it round-trips. Both engines now emit byte-identical documents; mx check 0 errors on each. OPEN_LINK is still unwritten by both (gen calls it OpenLinkClientAction and its Address is an element, not a string) \u2014 the syntax topic now says so instead of listing it as available.", "file": "`mdl/backend/modelsdk/widget_write.go` (clientActionToGen), `sdk/mpr/writer_widgets_action.go` (serializeClientAction), `mdl/executor/cmd_pages_describe_output.go` (renderClientActionMDL), `cmd/mxcli/syntax/features_page.go`; example `mdl-examples/bug-tests/captrack-10-sign-out-action.mdl`", "insight": "When one engine refuses something and points at the other, CHECK THE OTHER before repeating the advice \u2014 the refusal is visible and the fallback is not, so the recommended escape hatch can be the strictly worse path. The structural tell is the shape of the default branch: modelsdk's raises, legacy's returns Forms$NoAction, and a silent default in a serializer converts every unimplemented type into data loss rather than an error. Grep for the fallthrough before trusting a switch. Note the control this needs: a test that SIGN_OUT is no longer NoAction can pass because someone softened the default, so pin the fallback separately with a type that is still unimplemented (OPEN_LINK). Reported as CapTrackV2 FINDINGS \u00a710."}
+{"area": "mdl/executor", "date": "2026-09-04", "symptom": "A navigation menu's LOG-OUT item could not be authored and did not survive a round trip. MDL's `menu item` took PAGE or MICROFLOW only, so there was no spelling for it; and ako/TestApp's sign-out menu item read back as a plain `menu item 'Item 5';`, so DESCRIBE -> exec turned a working log-out entry into a dead one \u2014 silently, with `mx check` clean.", "cause": "A menu item's action goes through FOUR places that share no code with the button path: menuActionToGen (menu document, modelsdk), navMenuAction (navigation profile, raw BSON), resolveMenuAction (modelsdk read) and parseNavMenuItem (legacy read). Both writers ended in a NoAction default and both readers left the type name unmapped. Added SIGN_OUT to navMenuItemDef in the grammar (it consumes no qualifiedName, so it is read separately from the PAGE/MICROFLOW switch or an ICON after it is mis-assigned), carried it as ActionType \"SignOutAction\" / NavMenuItemSpec.SignOut, and wired all four. Studio Pro stores the same Forms$SignOutClientAction a button carries: DisabledDuringExecution true, nothing else.", "file": "`mdl/grammar/MDLParser.g4` (navMenuItemDef), `mdl/ast/ast_navigation.go`, `mdl/visitor/visitor_navigation.go`, `mdl/executor/cmd_menus.go` + `cmd_navigation.go` (conversion + printMenuMDL + the show summary), `mdl/types/navigation.go`, `mdl/backend/modelsdk/menu_write.go` + `navigation_write.go` + `navigation_read.go`, `sdk/mpr/parser_misc.go`; example `mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl`", "insight": "A round trip closes only if the READER produces the exact string the WRITER consumes \u2014 here both readers had a raw-type-name fallback that looked like it preserved information (ActionType became \"Forms$SignOutClientAction\") while breaking the round trip, because DESCRIBE and the writers key on \"SignOutAction\". A fallback that stores the raw name is not the same as handling the case, and it hides the gap better than a NoAction default would. Also: the same logical action reaches storage through four unrelated switches (two writers x two constructs, two readers), so fixing the button path proved nothing about the menu path \u2014 grep for every switch on the action before calling such a fix complete. Controlled by neutralising both readers and re-reading TestApp: `Item 5 -> sign out` goes back to `Item 5`."}
+{"area": "mdl/executor", "date": "2026-09-04", "symptom": "`ACTIONBUTTON \u2026 (Action: OPEN_LINK 'https://\u2026')` was written by neither engine: modelsdk refused it, legacy fell through to its quiet default and wrote Forms$NoAction, so the button rendered and did nothing with check, exec and mx check all clean.", "cause": "Same missing-case defect as SIGN_OUT, but with two traps a reference settled and reasoning would not. (1) The STORAGE NAME is Forms$OpenLinkClientAction, while the semantic type is LinkClientAction and the executor stamped `Forms$LinkClientAction` \u2014 a wrong $Type that never reached disk only because nothing could write the action. (2) The address is not a string field but a nested Forms$StaticOrDynamicString. Pinned against 31 Studio Pro link buttons (ako/TestApp, FeedbackModule): exactly five keys, LinkType \"Web\" in all 31, and 6 of 31 DYNAMIC (IsDynamic true + AttributeRef + empty Value). MDL authors the static form only, so DESCRIBE flags a dynamic one instead of printing its address as a literal.", "file": "`mdl/backend/modelsdk/widget_write.go` (clientActionToGen + staticAddressToGen), `sdk/mpr/writer_widgets_action.go`, `mdl/executor/cmd_pages_builder_v3.go` ($Type), `mdl/executor/cmd_pages_describe_output.go`, `cmd/mxcli/syntax/features_page.go`; example `mdl-examples/bug-tests/captrack-10-open-link-action.mdl`", "insight": "gen declares a fourth property on Forms$StaticOrDynamicString \u2014 `Attribute` \u2014 that not one of the 31 stored documents carries. Writing it would be the 'never invent a key' failure: a document mxbuild accepts and Studio Pro cannot open. When gen offers more properties than the references show, the references win. Second lesson, about controls: the SIGN_OUT commit used LinkClientAction as its 'still unimplemented' control, and implementing OPEN_LINK silently invalidated it \u2014 the test then failed for a good reason, but a control naming a specific unimplemented feature has a shelf life. Point it at something structurally unwritable instead (ShowHomePageClientAction: no gen type, no metamodel counterpart, no MDL statement that builds one)."}
diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go
index 35cdd470de..9522aea0c3 100644
--- a/cmd/mxcli/syntax/features_page.go
+++ b/cmd/mxcli/syntax/features_page.go
@@ -140,7 +140,7 @@ func init() {
"button style", "primary", "danger", "success",
"icon", "linkbutton", "link button",
},
- Syntax: "Action: SAVE_CHANGES\nAction: SAVE_CHANGES CLOSE_PAGE -- save, then close the pop-up\nAction: CANCEL_CHANGES\nAction: CANCEL_CHANGES CLOSE_PAGE\nAction: CLOSE_PAGE\nAction: DELETE\nAction: DELETE CLOSE_PAGE\nAction: DELETE_OBJECT\nAction: NANOFLOW Module.NF\nAction: OPEN_LINK 'https://example.com'\nAction: SIGN_OUT\nAction: COMPLETE_TASK 'OutcomeName'\nAction: SHOW_PAGE Module.Page\nAction: SHOW_PAGE Module.Page(Param: $currentObject)\nAction: MICROFLOW Module.MF\nAction: MICROFLOW Module.MF(Param: $val)\nAction: CREATE_OBJECT Module.Entity THEN SHOW_PAGE Module.Page\n\nA SHOW_PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\nIcon: 'Module.IconCollection.IconName' -- e.g. 'Atlas_Core.Atlas_Filled.pencil'\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).",
+ Syntax: "Action: SAVE_CHANGES\nAction: SAVE_CHANGES CLOSE_PAGE -- save, then close the pop-up\nAction: CANCEL_CHANGES\nAction: CANCEL_CHANGES CLOSE_PAGE\nAction: CLOSE_PAGE\nAction: DELETE\nAction: DELETE CLOSE_PAGE\nAction: DELETE_OBJECT\nAction: NANOFLOW Module.NF\nAction: OPEN_LINK 'https://example.com'\nAction: SIGN_OUT\nAction: COMPLETE_TASK 'OutcomeName'\nAction: SHOW_PAGE Module.Page\nAction: SHOW_PAGE Module.Page(Param: $currentObject)\nAction: MICROFLOW Module.MF\nAction: MICROFLOW Module.MF(Param: $val)\nAction: CREATE_OBJECT Module.Entity THEN SHOW_PAGE Module.Page\n\nA SHOW_PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\n\nOPEN_LINK takes a static web address and stores it as a\nForms$StaticOrDynamicString. Mendix also supports a DYNAMIC address, read\nfrom an attribute at runtime; MDL cannot author that one, and DESCRIBE\nflags such a button rather than printing its address as a literal.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\nIcon: 'Module.IconCollection.IconName' -- e.g. 'Atlas_Core.Atlas_Filled.pencil'\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).",
Example: "ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\nACTIONBUTTON btnEdit (Caption: 'Edit',\n Action: SHOW_PAGE Module.EditPage(Item: $currentObject))\nLINKBUTTON btnDelete (Caption: 'Delete', Action: DELETE,\n Icon: 'Atlas_Core.Atlas_Filled.pencil')",
SeeAlso: []string{"page.widgets"},
})
@@ -375,7 +375,7 @@ func init() {
"menu", "menus", "menu document", "menu item",
},
Syntax: "CREATE [OR MODIFY] MENU Module.Name [FOLDER 'path'] (\n" +
- " MENU ITEM '
' [PAGE Module.Page | MICROFLOW Module.Flow] [ICON Module.Collection.name];\n" +
+ " MENU ITEM '' [PAGE Module.Page | MICROFLOW Module.Flow | SIGN_OUT] [ICON Module.Collection.name];\n" +
" MENU '' [ICON Module.Collection.name] ( );\n" +
");\n" +
"DESCRIBE MENU Module.Name;\n" +
@@ -394,6 +394,9 @@ func init() {
"-- SHOW NAVIGATION MENU and ALTER NAVIGATION. Both use these same items.\n" +
"-- * OR MODIFY replaces the item list wholesale; an omitted item is removed.\n" +
"-- The document's identity and export level are preserved.\n" +
+ "-- * SIGN_OUT is the log-out menu item. It needs no target and stores the\n" +
+ "-- same Forms$SignOutClientAction a sign-out BUTTON carries. Works both\n" +
+ "-- here and in a navigation profile's menu.\n" +
"-- * ICON names an icon collection entry. A glyph or image icon cannot be\n" +
"-- expressed in MDL; DESCRIBE flags those rather than dropping them silently.\n" +
"-- * A page with required parameters cannot be opened from a menu item\n" +
diff --git a/mdl-examples/bug-tests/captrack-10-open-link-action.mdl b/mdl-examples/bug-tests/captrack-10-open-link-action.mdl
new file mode 100644
index 0000000000..3f736ec467
--- /dev/null
+++ b/mdl-examples/bug-tests/captrack-10-open-link-action.mdl
@@ -0,0 +1,53 @@
+-- The other half of the same defect as SIGN_OUT (CapTrackV2 FINDINGS §10):
+-- `ACTIONBUTTON … (Action: OPEN_LINK '…')` was written by NEITHER engine.
+--
+-- modelsdk: refused — "client action *pages.LinkClientAction not yet
+-- supported by the modelsdk engine"
+-- legacy: fell through to the quiet default and wrote Forms$NoAction, so
+-- the button rendered and did nothing, with `mxcli check`, `exec`
+-- and `mx check` all clean.
+--
+-- Two things a reference settles that reasoning would not. The STORAGE NAME is
+-- Forms$OpenLinkClientAction, not the "Forms$LinkClientAction" the semantic type
+-- carried — a wrong $Type that never reached disk only because nothing could
+-- write the action. And the address is not a string field: it is a nested
+-- Forms$StaticOrDynamicString.
+--
+-- Pinned against 31 Studio Pro-authored link buttons (ako/TestApp,
+-- FeedbackModule): exactly five keys, LinkType "Web" in all 31.
+--
+-- { "$Type": "Forms$OpenLinkClientAction",
+-- "Address": { "$Type": "Forms$StaticOrDynamicString",
+-- "AttributeRef": null, "IsDynamic": false,
+-- "Value": "https://www.mendix.com/" },
+-- "DisabledDuringExecution": true,
+-- "LinkType": "Web" }
+--
+-- 6 of those 31 are DYNAMIC (IsDynamic true, an AttributeRef, an empty Value) —
+-- the address is read from an attribute at runtime. MDL cannot author that, so
+-- DESCRIBE flags such a button instead of printing its address as a literal,
+-- which would round-trip into a different link.
+--
+-- Verify:
+-- mxcli exec captrack-10-open-link-action.mdl -p app.mpr
+-- mxcli -p app.mpr -c "describe page OpenLink.P_Links"
+-- -- must emit: Action: open_link 'https://example.com'
+-- mx check -p app.mpr -- 0 errors
+
+create module OpenLink;
+/
+
+create or replace page OpenLink.P_Links
+ (Title: 'Links', Layout: Atlas_Core.Atlas_Default)
+{
+ container cLinks {
+ actionbutton btnDocs (Caption: 'Docs', Action: open_link 'https://example.com')
+
+ -- A link button is the render mode a URL usually wants.
+ linkbutton lnkHome (Caption: 'Mendix', Action: open_link 'https://www.mendix.com/')
+
+ -- CONTROL: the actions that already worked are untouched.
+ actionbutton btnClose (Caption: 'Close', Action: close_page)
+ }
+}
+/
diff --git a/mdl-examples/bug-tests/captrack-10-sign-out-action.mdl b/mdl-examples/bug-tests/captrack-10-sign-out-action.mdl
new file mode 100644
index 0000000000..6432a9052a
--- /dev/null
+++ b/mdl-examples/bug-tests/captrack-10-sign-out-action.mdl
@@ -0,0 +1,43 @@
+-- CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused by
+-- the default engine:
+--
+-- client action *pages.SignOutClientAction not yet supported by the
+-- modelsdk engine — rerun with MXCLI_ENGINE=legacy
+--
+-- The refusal was honest. The advice was not: the legacy writer had no case for
+-- the action either, so it fell through to the default and wrote
+-- `Forms$NoAction`. The button rendered, said "Sign out", and did nothing —
+-- with `mxcli check`, `exec` and `mx check` all clean. Measured on Mendix 11.13:
+--
+-- before, either engine: describe -> actionbutton btnOut (Caption: 'Sign out')
+-- stored -> Forms$NoAction
+-- after, either engine: describe -> ... Action: sign_out
+-- stored -> Forms$SignOutClientAction,
+-- DisabledDuringExecution: true
+--
+-- That two-key shape is pinned against a Studio Pro-authored sign-out button
+-- (ako/TestApp). The reference is provably Studio Pro's rather than mxcli's,
+-- because until this fix NEITHER engine could emit the type.
+--
+-- Verify:
+-- mxcli exec captrack-10-sign-out-action.mdl -p app.mpr
+-- mxcli -p app.mpr -c "describe page SignOut.P_Account"
+-- -- must emit: Action: sign_out
+-- MXCLI_ENGINE=legacy mxcli exec … -p other.mpr -- same document
+-- mx check -p app.mpr -- 0 errors, before and after (this never was a
+-- build error, which is what made it dangerous)
+
+create module SignOut;
+/
+
+create or replace page SignOut.P_Account
+ (Title: 'Account', Layout: Atlas_Core.Atlas_Default)
+{
+ container cActions {
+ actionbutton btnSignOut (Caption: 'Sign out', Action: sign_out, ButtonStyle: Default)
+
+ -- CONTROL: the actions that already worked are untouched by the new case.
+ actionbutton btnClose (Caption: 'Close', Action: close_page)
+ }
+}
+/
diff --git a/mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl b/mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl
new file mode 100644
index 0000000000..5a12bbb900
--- /dev/null
+++ b/mdl-examples/bug-tests/captrack-10-sign-out-menu-item.mdl
@@ -0,0 +1,54 @@
+-- A navigation menu can carry a LOG-OUT item, and mxcli could neither author
+-- one nor read one back. This is the menu half of CapTrackV2 FINDINGS §10; the
+-- button half is captrack-10-sign-out-action.mdl.
+--
+-- MDL's menu item took PAGE or MICROFLOW only, so:
+--
+-- authoring: there was no spelling for it at all.
+-- reading: ako/TestApp's sign-out menu item came back as a plain
+-- `menu item 'Item 5';` — so DESCRIBE -> exec turned a working
+-- log-out entry into a dead one, silently, `mx check` clean.
+--
+-- Measured on ako/TestApp (Mendix 11.14), before and after:
+--
+-- before: show navigation menu Responsive -> Item 5
+-- after: show navigation menu Responsive -> Item 5 -> sign out
+--
+-- Studio Pro stores it as the same element a sign-out BUTTON carries —
+-- Forms$SignOutClientAction, DisabledDuringExecution true, nothing else — which
+-- is why SIGN_OUT sits beside PAGE and MICROFLOW rather than having a syntax of
+-- its own. It needs no target.
+--
+-- Both menu constructs are covered: a standalone menu document and the menu
+-- inside a navigation profile. They have separate writers and separate readers,
+-- and BOTH ended in a NoAction default.
+--
+-- Verify:
+-- mxcli exec captrack-10-sign-out-menu-item.mdl -p app.mpr
+-- mxcli -p app.mpr -c "describe menu SignOutMenu.Account_Menu"
+-- -- must emit: menu item 'Sign out' sign_out;
+-- mx check -p app.mpr -- 0 errors
+
+create module SignOutMenu;
+/
+create or replace page SignOutMenu.Home (Title: 'Home', Layout: Atlas_Core.Atlas_Default)
+{
+ dynamictext t (Content: 'home')
+}
+/
+
+create or modify menu SignOutMenu.Account_Menu (
+ menu item 'Home' page SignOutMenu.Home;
+
+ -- The item that could not be spelled.
+ menu item 'Sign out' sign_out;
+
+ -- SIGN_OUT names no target, so it must not disturb the qualified-name list it
+ -- shares with PAGE/MICROFLOW and ICON — an icon after it still lands as one.
+ menu item 'Log out' sign_out icon Atlas_Core.Atlas.home;
+
+ -- CONTROL: an item with no action must stay actionless. A conversion that
+ -- stamped every actionless item as sign-out would look correct above.
+ menu item 'Plain';
+);
+/
diff --git a/mdl/ast/ast_navigation.go b/mdl/ast/ast_navigation.go
index 8dd26a300a..af81e8f317 100644
--- a/mdl/ast/ast_navigation.go
+++ b/mdl/ast/ast_navigation.go
@@ -28,6 +28,7 @@ type NavMenuItemDef struct {
Caption string // from STRING_LITERAL
Page *QualifiedName // PAGE target
Microflow *QualifiedName // MICROFLOW target
+ SignOut bool // SIGN_OUT — the third action a menu item can carry
Icon string // ICON 'Module.Collection.name', empty for none
Items []NavMenuItemDef // Sub-items (for MENU 'caption' (...))
}
diff --git a/mdl/backend/modelsdk/menu_signout_test.go b/mdl/backend/modelsdk/menu_signout_test.go
new file mode 100644
index 0000000000..74b2eba42a
--- /dev/null
+++ b/mdl/backend/modelsdk/menu_signout_test.go
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package modelsdkbackend
+
+import (
+ "testing"
+
+ "github.com/mendixlabs/mxcli/mdl/types"
+ genPages "github.com/mendixlabs/mxcli/modelsdk/gen/pages"
+)
+
+// A sign-out MENU ITEM reaches storage through two writers that do not share
+// code with the button path: menuActionToGen for a standalone menu document,
+// and navMenuAction for the menu inside a navigation profile. Both ended in a
+// NoAction default, so both silently produced a dead entry.
+//
+// Studio Pro stores the same element a button carries — measured on
+// ako/TestApp's sign-out menu item: Forms$SignOutClientAction with
+// DisabledDuringExecution true and nothing else.
+
+func TestMenuActionToGen_SignOut(t *testing.T) {
+ el := menuActionToGen(&types.NavMenuItem{Caption: "Sign out", ActionType: "SignOutAction"})
+ g, ok := el.(*genPages.SignOutClientAction)
+ if !ok {
+ t.Fatalf("got %T, want *pages.SignOutClientAction — a menu item's sign-out fell to NoAction", el)
+ }
+ if !g.DisabledDuringExecution() {
+ t.Error("DisabledDuringExecution is false; the Studio Pro reference stores true")
+ }
+}
+
+// CONTROL: the two actions that already worked, plus the actionless item, are
+// unchanged. A writer that answered SignOut for everything would pass the test
+// above.
+func TestMenuActionToGen_OtherItemsUnchanged(t *testing.T) {
+ cases := []struct {
+ name string
+ item *types.NavMenuItem
+ want string
+ }{
+ {"page", &types.NavMenuItem{Page: "M.P"}, "Forms$FormAction"},
+ {"microflow", &types.NavMenuItem{Microflow: "M.MF"}, "Forms$MicroflowAction"},
+ {"plain", &types.NavMenuItem{Caption: "Plain"}, "Forms$NoAction"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := menuActionToGen(c.item).TypeName(); got != c.want {
+ t.Errorf("$Type = %q, want %q", got, c.want)
+ }
+ })
+ }
+}
+
+// The navigation-profile writer is the other half, and it builds raw BSON.
+func TestNavMenuAction_SignOut(t *testing.T) {
+ doc := navMenuAction(types.NavMenuItemSpec{Caption: "Sign out", SignOut: true})
+ got := map[string]any{}
+ for _, e := range doc {
+ got[e.Key] = e.Value
+ }
+ if got["$Type"] != "Forms$SignOutClientAction" {
+ t.Fatalf("$Type = %v, want Forms$SignOutClientAction", got["$Type"])
+ }
+ if got["DisabledDuringExecution"] != true {
+ t.Errorf("DisabledDuringExecution = %v, want true", got["DisabledDuringExecution"])
+ }
+ if len(doc) != 3 {
+ t.Errorf("the action has %d keys, want 3 ($ID, $Type, DisabledDuringExecution): %v", len(doc), doc)
+ }
+}
+
+// CONTROL: an item with no action still writes Forms$NoAction, so the test
+// above proves something about SIGN_OUT rather than about the default going
+// away.
+func TestNavMenuAction_PlainItemStillNoAction(t *testing.T) {
+ doc := navMenuAction(types.NavMenuItemSpec{Caption: "Plain"})
+ for _, e := range doc {
+ if e.Key == "$Type" && e.Value != "Forms$NoAction" {
+ t.Errorf("$Type = %v, want Forms$NoAction", e.Value)
+ }
+ }
+}
+
+// The reader has to produce the name the writers consume, or the round trip
+// does not close. That pairing is the actual fix: reading TestApp's item as a
+// raw type name would have described it as a plain item.
+func TestResolveMenuAction_SignOutUsesTheWriterSName(t *testing.T) {
+ item := &types.NavMenuItem{}
+ resolveMenuAction(item, genPages.NewSignOutClientAction())
+ if item.ActionType != "SignOutAction" {
+ t.Errorf("ActionType = %q, want SignOutAction — the writers key on that exact string",
+ item.ActionType)
+ }
+}
diff --git a/mdl/backend/modelsdk/menu_write.go b/mdl/backend/modelsdk/menu_write.go
index 80b5a9d5e3..0f6e4869e1 100644
--- a/mdl/backend/modelsdk/menu_write.go
+++ b/mdl/backend/modelsdk/menu_write.go
@@ -156,6 +156,16 @@ func menuActionToGen(item *types.NavMenuItem) element.Element {
ms.SetMicroflowQualifiedName(item.Microflow)
a.SetMicroflowSettings(ms)
return a
+ case item.ActionType == "SignOutAction":
+ // A menu item's sign-out is the same Forms$SignOutClientAction a button
+ // carries — measured on ako/TestApp's own sign-out menu item, which
+ // stores exactly DisabledDuringExecution: true and nothing else. Before
+ // this case it fell to NoAction below, so describe -> exec turned a
+ // working sign-out item into a dead one.
+ a := genPages.NewSignOutClientAction()
+ a.SetID(element.ID(mmpr.GenerateID()))
+ a.SetDisabledDuringExecution(true)
+ return a
default:
a := genPages.NewNoClientAction()
a.SetID(element.ID(mmpr.GenerateID()))
diff --git a/mdl/backend/modelsdk/navigation_read.go b/mdl/backend/modelsdk/navigation_read.go
index 916978a6e2..7b98df53d5 100644
--- a/mdl/backend/modelsdk/navigation_read.go
+++ b/mdl/backend/modelsdk/navigation_read.go
@@ -296,6 +296,12 @@ func resolveMenuAction(item *types.NavMenuItem, action element.Element) {
if ms, ok := a.MicroflowSettings().(*genPages.MicroflowSettings); ok && ms != nil {
item.Microflow = ms.MicroflowQualifiedName()
}
+ case *genPages.SignOutClientAction:
+ // Named explicitly rather than falling to the raw-type-name default
+ // below: DESCRIBE and the writer both key on "SignOutAction", and a
+ // round trip only closes if the reader produces the name the writer
+ // consumes.
+ item.ActionType = "SignOutAction"
default:
t := action.TypeName()
switch {
diff --git a/mdl/backend/modelsdk/navigation_write.go b/mdl/backend/modelsdk/navigation_write.go
index c200d97549..1fd184d02e 100644
--- a/mdl/backend/modelsdk/navigation_write.go
+++ b/mdl/backend/modelsdk/navigation_write.go
@@ -341,6 +341,15 @@ func navMenuAction(mi types.NavMenuItemSpec) bson.D {
}},
}
}
+ if mi.SignOut {
+ // Same element a sign-out BUTTON carries, pinned against the sign-out
+ // menu item in ako/TestApp: two properties and nothing else.
+ return bson.D{
+ {Key: "$ID", Value: navID()},
+ {Key: "$Type", Value: "Forms$SignOutClientAction"},
+ {Key: "DisabledDuringExecution", Value: true},
+ }
+ }
return bson.D{
{Key: "$ID", Value: navID()},
{Key: "$Type", Value: "Forms$NoAction"},
diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go
index 4ee902bebd..773e157a6b 100644
--- a/mdl/backend/modelsdk/widget_write.go
+++ b/mdl/backend/modelsdk/widget_write.go
@@ -1445,6 +1445,27 @@ func formSettingsToGen(pageName string) element.Element {
return ps
}
+// staticAddressToGen builds the Forms$StaticOrDynamicString an open-link action
+// nests as its Address.
+//
+// Only the STATIC form is authored: MDL spells `OPEN_LINK 'https://…'` and has
+// no syntax for the dynamic one. 6 of the 31 Studio Pro references are dynamic
+// (IsDynamic true, an AttributeRef, an empty Value) — DESCRIBE flags those
+// rather than rendering them as a literal, because a dynamic address printed as
+// a static one round-trips into a different link.
+//
+// AttributeRef is deliberately left unset. gen declares a fourth property here,
+// `Attribute`, that not one of the 31 documents carries; writing a key Mendix
+// does not store is what makes a document mxbuild accepts and Studio Pro cannot
+// open (CLAUDE.md, "Overlay Writes: Never Invent a Key").
+func staticAddressToGen(address string) element.Element {
+ s := genPg.NewStaticOrDynamicString()
+ assignID(s)
+ s.SetIsDynamic(false)
+ s.SetValue(address)
+ return s
+}
+
// clientActionToGen converts a widget client action. Simple actions are supported;
// the page/microflow/nanoflow/create-object actions (which carry settings sub-
// objects) are refused loudly for now.
@@ -1483,6 +1504,46 @@ func clientActionToGen(a pages.ClientAction) (element.Element, error) {
g.SetNumberOfPagesToClose2("")
g.SetPageSettings(formSettingsToGen(x.PageName))
return g, nil
+ case *pages.LinkClientAction:
+ // open_link → Forms$OpenLinkClientAction. Note the storage name: the
+ // semantic type is LinkClientAction and the executor stamped it
+ // "Forms$LinkClientAction", which is not what Mendix stores — a wrong
+ // $Type that never reached disk only because neither engine could write
+ // the action at all.
+ //
+ // Pinned against 31 Studio Pro-authored link buttons (ako/TestApp,
+ // FeedbackModule): exactly five keys, LinkType "Web" in all 31, and the
+ // address nested as a Forms$StaticOrDynamicString.
+ g := genPg.NewOpenLinkClientAction()
+ if x.ID != "" {
+ g.SetID(element.ID(x.ID))
+ }
+ assignID(g)
+ g.SetDisabledDuringExecution(true)
+ linkType := string(x.LinkType)
+ if linkType == "" {
+ linkType = "Web"
+ }
+ g.SetLinkType(linkType)
+ g.SetAddress(staticAddressToGen(x.Address))
+ return g, nil
+ case *pages.SignOutClientAction:
+ // sign_out → Forms$SignOutClientAction. One property, and the reference
+ // pins its value: a Studio Pro-authored sign-out button (ako/TestApp,
+ // Mendix 11) stores exactly
+ //
+ // { "$Type": "Forms$SignOutClientAction", "DisabledDuringExecution": true }
+ //
+ // That document is provably Studio Pro's rather than mxcli's, because
+ // until now NEITHER engine could emit the type — modelsdk refused it and
+ // legacy wrote Forms$NoAction (CapTrackV2 FINDINGS §10).
+ g := genPg.NewSignOutClientAction()
+ if x.ID != "" {
+ g.SetID(element.ID(x.ID))
+ }
+ assignID(g)
+ g.SetDisabledDuringExecution(true)
+ return g, nil
case *pages.SetTaskOutcomeClientAction:
g := genPg.NewSetTaskOutcomeClientAction()
if x.ID != "" {
diff --git a/mdl/backend/modelsdk/widget_write_signout_test.go b/mdl/backend/modelsdk/widget_write_signout_test.go
new file mode 100644
index 0000000000..a454018d11
--- /dev/null
+++ b/mdl/backend/modelsdk/widget_write_signout_test.go
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package modelsdkbackend
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/mendixlabs/mxcli/model"
+ genPg "github.com/mendixlabs/mxcli/modelsdk/gen/pages"
+ "github.com/mendixlabs/mxcli/sdk/pages"
+)
+
+// CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused
+// outright by the default engine:
+//
+// client action *pages.SignOutClientAction not yet supported by the
+// modelsdk engine — rerun with MXCLI_ENGINE=legacy
+//
+// The refusal was honest; the advice was not. The legacy writer had no case for
+// the action either and fell through to Forms$NoAction, so the recommended
+// escape hatch produced a button that rendered, said "Sign out", and did
+// nothing — with check, exec and mx check all clean.
+//
+// The document is one property, pinned against a Studio Pro-authored button
+// (ako/TestApp, Mendix 11):
+//
+// { "$Type": "Forms$SignOutClientAction", "DisabledDuringExecution": true }
+//
+// That reference is provably Studio Pro's rather than mxcli's, because until
+// this change NEITHER engine could emit the type.
+func TestClientActionToGen_SignOut(t *testing.T) {
+ el, err := clientActionToGen(&pages.SignOutClientAction{
+ BaseElement: model.BaseElement{ID: "action-id"},
+ })
+ if err != nil {
+ t.Fatalf("SIGN_OUT is still refused by the modelsdk engine: %v", err)
+ }
+ g, ok := el.(*genPg.SignOutClientAction)
+ if !ok {
+ t.Fatalf("got %T, want *pages.SignOutClientAction", el)
+ }
+ if g.TypeName() != "Forms$SignOutClientAction" {
+ t.Errorf("$Type = %q, want Forms$SignOutClientAction", g.TypeName())
+ }
+ if !g.DisabledDuringExecution() {
+ t.Error("DisabledDuringExecution is false; the Studio Pro reference stores true")
+ }
+}
+
+// CONTROL 1: an action that is still unimplemented must still be REFUSED, not
+// quietly written. Without this the tests above could pass because the default
+// branch had been softened, which is the exact failure the legacy engine had.
+//
+// ShowHomePage is the stand-in: gen has no type for it, generated/metamodel has
+// no Pages counterpart, and no MDL statement builds one — so it is a semantic
+// type nothing can write, which is precisely what this control needs. (The
+// earlier draft used LinkClientAction, which stopped being a valid control the
+// moment OPEN_LINK was implemented.)
+func TestClientActionToGen_StillRefusesWhatItCannotWrite(t *testing.T) {
+ _, err := clientActionToGen(&pages.ShowHomePageClientAction{
+ BaseElement: model.BaseElement{ID: "home-id"},
+ })
+ if err == nil {
+ t.Fatal("an action with no writer was accepted, which means dropping it")
+ }
+ if !strings.Contains(err.Error(), "not yet supported") {
+ t.Errorf("unexpected message: %v", err)
+ }
+}
+
+// OPEN_LINK, the other action that used to fall through. gen calls it
+// OpenLinkClientAction — the storage name differs from the semantic type's
+// "Forms$LinkClientAction", a wrong $Type that never reached disk only because
+// nothing could write the action at all.
+//
+// Pinned against 31 Studio Pro-authored link buttons (ako/TestApp,
+// FeedbackModule): five keys, LinkType "Web" in all 31, address nested as a
+// Forms$StaticOrDynamicString.
+func TestClientActionToGen_OpenLink(t *testing.T) {
+ el, err := clientActionToGen(&pages.LinkClientAction{
+ BaseElement: model.BaseElement{ID: "link-id"},
+ LinkType: pages.LinkTypeWeb,
+ Address: "https://example.com",
+ })
+ if err != nil {
+ t.Fatalf("OPEN_LINK is still refused: %v", err)
+ }
+ g, ok := el.(*genPg.OpenLinkClientAction)
+ if !ok {
+ t.Fatalf("got %T, want *pages.OpenLinkClientAction", el)
+ }
+ if g.TypeName() != "Forms$OpenLinkClientAction" {
+ t.Errorf("$Type = %q — Mendix stores OpenLink, not Link", g.TypeName())
+ }
+ if g.LinkType() != "Web" {
+ t.Errorf("LinkType = %q, want Web", g.LinkType())
+ }
+ addr, ok := g.Address().(*genPg.StaticOrDynamicString)
+ if !ok {
+ t.Fatalf("Address is %T, want *pages.StaticOrDynamicString", g.Address())
+ }
+ if addr.IsDynamic() {
+ t.Error("IsDynamic is true; MDL authors the static form only")
+ }
+ if addr.Value() != "https://example.com" {
+ t.Errorf("Value = %q, want the authored URL", addr.Value())
+ }
+}
+
+// An empty LinkType must not reach storage: Mendix's enum is Call/Email/Text/Web
+// and every one of the 31 references is Web, so that is the default rather than
+// writing a blank a build would reject.
+func TestClientActionToGen_OpenLinkDefaultsLinkType(t *testing.T) {
+ el, err := clientActionToGen(&pages.LinkClientAction{
+ BaseElement: model.BaseElement{ID: "link-id"},
+ Address: "https://example.com",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if lt := el.(*genPg.OpenLinkClientAction).LinkType(); lt != "Web" {
+ t.Errorf("LinkType = %q, want Web", lt)
+ }
+}
+
+// CONTROL 2: the actions that already worked are untouched.
+func TestClientActionToGen_ExistingActionsUnchanged(t *testing.T) {
+ for _, a := range []pages.ClientAction{
+ &pages.SaveChangesClientAction{BaseElement: model.BaseElement{ID: "a"}},
+ &pages.ClosePageClientAction{BaseElement: model.BaseElement{ID: "b"}},
+ &pages.DeleteClientAction{BaseElement: model.BaseElement{ID: "c"}},
+ } {
+ if _, err := clientActionToGen(a); err != nil {
+ t.Errorf("%T was refused: %v", a, err)
+ }
+ }
+}
diff --git a/mdl/executor/cmd_menus.go b/mdl/executor/cmd_menus.go
index 21fc9dcb02..d985ab06cf 100644
--- a/mdl/executor/cmd_menus.go
+++ b/mdl/executor/cmd_menus.go
@@ -148,6 +148,11 @@ func menuItemsFromAST(defs []ast.NavMenuItemDef) []*types.NavMenuItem {
} else if d.Microflow != nil {
item.Microflow = d.Microflow.String()
item.ActionType = "MicroflowAction"
+ } else if d.SignOut {
+ // Studio Pro stores a sign-out menu item as the same
+ // Forms$SignOutClientAction a button carries (measured on
+ // ako/TestApp), so it is an ActionType rather than a target.
+ item.ActionType = "SignOutAction"
} else {
item.ActionType = "NoAction"
}
diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go
index 9d9a32f358..5c6a36c201 100644
--- a/mdl/executor/cmd_navigation.go
+++ b/mdl/executor/cmd_navigation.go
@@ -126,6 +126,7 @@ func convertMenuItemDef(def ast.NavMenuItemDef) types.NavMenuItemSpec {
if def.Microflow != nil {
spec.Microflow = def.Microflow.String()
}
+ spec.SignOut = def.SignOut
for _, sub := range def.Items {
spec.Items = append(spec.Items, convertMenuItemDef(sub))
}
@@ -383,6 +384,9 @@ func menuItemTarget(item *types.NavMenuItem) string {
if item.Microflow != "" {
return " -> MF:" + item.Microflow
}
+ if item.ActionType == "SignOutAction" {
+ return " -> sign out"
+ }
return ""
}
@@ -402,6 +406,8 @@ func printMenuMDL(w io.Writer, items []*types.NavMenuItem, depth int, reproducer
fmt.Fprintf(w, "%smenu item '%s' page %s%s;\n", indent, item.Caption, item.Page, icon)
} else if item.Microflow != "" {
fmt.Fprintf(w, "%smenu item '%s' microflow %s%s;\n", indent, item.Caption, item.Microflow, icon)
+ } else if item.ActionType == "SignOutAction" {
+ fmt.Fprintf(w, "%smenu item '%s' sign_out%s;\n", indent, item.Caption, icon)
} else {
fmt.Fprintf(w, "%smenu item '%s'%s;\n", indent, item.Caption, icon)
}
diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go
index 555fdee1cd..d448c0587c 100644
--- a/mdl/executor/cmd_pages_builder_v3.go
+++ b/mdl/executor/cmd_pages_builder_v3.go
@@ -1449,8 +1449,12 @@ func (pb *pageBuilder) buildClientActionV3(action *ast.ActionV3) (pages.ClientAc
case "openLink":
return &pages.LinkClientAction{
BaseElement: model.BaseElement{
- ID: model.ID(types.GenerateID()),
- TypeName: "Forms$LinkClientAction",
+ ID: model.ID(types.GenerateID()),
+ // Mendix stores this as Forms$OpenLinkClientAction — the
+ // storage name differs from the SDK type name, the split
+ // CLAUDE.md documents. The wrong value here never reached disk
+ // only because neither engine could write the action at all.
+ TypeName: "Forms$OpenLinkClientAction",
},
LinkType: pages.LinkTypeWeb,
Address: action.LinkURL,
diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go
index f96cab77b3..64d1d55d9c 100644
--- a/mdl/executor/cmd_pages_describe_output.go
+++ b/mdl/executor/cmd_pages_describe_output.go
@@ -1168,6 +1168,27 @@ func renderClientActionMDL(ctx *ExecContext, action map[string]any) string {
case "Forms$SetTaskOutcomeClientAction", "Pages$SetTaskOutcomeClientAction":
outcomeValue, _ := action["OutcomeValue"].(string)
return "complete_task '" + strings.ReplaceAll(outcomeValue, "'", "''") + "'"
+ case "Forms$SignOutClientAction", "Pages$SignOutClientAction":
+ return "sign_out"
+ case "Forms$OpenLinkClientAction", "Pages$OpenLinkClientAction":
+ // The address is a nested Forms$StaticOrDynamicString. MDL can spell the
+ // static form only; a DYNAMIC address (6 of the 31 Studio Pro references
+ // use one) reads its value from an attribute at runtime, so rendering it
+ // as a literal would round-trip into a different link. Say so instead.
+ addr := actionMapForKey(action, "Address")
+ if addr == nil {
+ return "open_link ''"
+ }
+ if isDynamic, _ := addr["IsDynamic"].(bool); isDynamic {
+ attr := ""
+ if ref := actionMapForKey(addr, "AttributeRef"); ref != nil {
+ attr, _ = ref["Attribute"].(string)
+ }
+ return "-- open_link with a dynamic address (" + attr + ") — MDL cannot author this; " +
+ "the button is left as-is"
+ }
+ value, _ := addr["Value"].(string)
+ return "open_link '" + strings.ReplaceAll(value, "'", "''") + "'"
case "Forms$NoClientAction", "Pages$NoClientAction":
return ""
default:
diff --git a/mdl/executor/menu_signout_test.go b/mdl/executor/menu_signout_test.go
new file mode 100644
index 0000000000..06fc80c948
--- /dev/null
+++ b/mdl/executor/menu_signout_test.go
@@ -0,0 +1,127 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package executor
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/mendixlabs/mxcli/mdl/ast"
+ "github.com/mendixlabs/mxcli/mdl/types"
+ "github.com/mendixlabs/mxcli/mdl/visitor"
+)
+
+// A navigation menu can carry a log-out item, and mxcli could neither author one
+// nor read one back. MDL's menu item took PAGE or MICROFLOW only, so:
+//
+// - authoring: there was no spelling for it at all;
+// - reading: ako/TestApp's sign-out menu item ("Item 5") came back as a
+// plain `menu item 'Item 5';`, so DESCRIBE -> exec turned a working
+// sign-out into a dead menu entry — silently, with `mx check` clean.
+//
+// Studio Pro stores it as the same Forms$SignOutClientAction a BUTTON carries
+// (measured on that item): DisabledDuringExecution true, and nothing else.
+
+func signOutMenuStmt(t *testing.T, src string) *ast.CreateMenuStmt {
+ t.Helper()
+ prog, errs := visitor.Build(src)
+ if len(errs) > 0 {
+ t.Fatalf("parse errors for %q: %v", src, errs)
+ }
+ stmt, ok := prog.Statements[0].(*ast.CreateMenuStmt)
+ if !ok {
+ t.Fatalf("got %T, want *ast.CreateMenuStmt", prog.Statements[0])
+ }
+ return stmt
+}
+
+// The spelling that did not exist.
+func TestMenuItem_SignOutParses(t *testing.T) {
+ stmt := signOutMenuStmt(t, `create or modify menu M.Main (
+ menu item 'Sign out' sign_out;
+);`)
+ if len(stmt.Items) != 1 {
+ t.Fatalf("got %d items, want 1", len(stmt.Items))
+ }
+ if !stmt.Items[0].SignOut {
+ t.Error("SIGN_OUT did not reach the AST")
+ }
+}
+
+// SIGN_OUT names no target, so it must not disturb the qualifiedName list that
+// PAGE/MICROFLOW and ICON share — an icon after it still has to land as an icon.
+func TestMenuItem_SignOutWithAnIcon(t *testing.T) {
+ stmt := signOutMenuStmt(t, `create or modify menu M.Main (
+ menu item 'Sign out' sign_out icon Atlas_Core.Atlas.home;
+);`)
+ item := stmt.Items[0]
+ if !item.SignOut {
+ t.Error("SignOut lost when an icon follows")
+ }
+ if item.Icon != "Atlas_Core.Atlas.home" {
+ t.Errorf("Icon = %q, want the collection entry — SIGN_OUT must consume no qualifiedName", item.Icon)
+ }
+}
+
+// The menu-document path: AST -> semantic model.
+func TestMenuItemsFromAST_SignOutBecomesAnActionType(t *testing.T) {
+ items := menuItemsFromAST([]ast.NavMenuItemDef{
+ {Caption: "Sign out", SignOut: true},
+ {Caption: "Plain"},
+ })
+ if items[0].ActionType != "SignOutAction" {
+ t.Errorf("ActionType = %q, want SignOutAction", items[0].ActionType)
+ }
+ // CONTROL: an item with no action is still NoAction. A conversion that
+ // stamped every actionless item as sign-out would satisfy the line above.
+ if items[1].ActionType != "NoAction" {
+ t.Errorf("a plain item became %q", items[1].ActionType)
+ }
+}
+
+// The navigation-profile path uses a different spec type and its own converter.
+func TestConvertMenuItemDef_CarriesSignOut(t *testing.T) {
+ spec := convertMenuItemDef(ast.NavMenuItemDef{Caption: "Sign out", SignOut: true})
+ if !spec.SignOut {
+ t.Error("SignOut did not reach NavMenuItemSpec")
+ }
+ // CONTROL: the two existing targets are untouched.
+ page := ast.QualifiedName{Module: "M", Name: "P"}
+ if got := convertMenuItemDef(ast.NavMenuItemDef{Caption: "Home", Page: &page}); got.Page != "M.P" || got.SignOut {
+ t.Errorf("a page item came back as %+v", got)
+ }
+}
+
+// DESCRIBE must emit the spelling exec accepts, or the round trip does not
+// close — which is the half that lost TestApp's item.
+func TestPrintMenuMDL_RendersSignOut(t *testing.T) {
+ var b bytes.Buffer
+ printMenuMDL(&b, []*types.NavMenuItem{
+ {Caption: "Sign out", ActionType: "SignOutAction"},
+ {Caption: "Plain", ActionType: "NoAction"},
+ }, 0, "CREATE NAVIGATION")
+
+ out := b.String()
+ if !strings.Contains(out, "menu item 'Sign out' sign_out;") {
+ t.Errorf("describe output does not round-trip the sign-out item:\n%s", out)
+ }
+ // CONTROL: a plain item must not gain an action.
+ if strings.Contains(out, "'Plain' sign_out") {
+ t.Errorf("a plain item was rendered as sign-out:\n%s", out)
+ }
+}
+
+// The whole point, end to end in one test: describe output must parse back to
+// the same thing. A renderer and a parser can each be individually right and
+// still not agree.
+func TestMenuItem_SignOutRoundTripsThroughDescribe(t *testing.T) {
+ var b bytes.Buffer
+ printMenuMDL(&b, []*types.NavMenuItem{{Caption: "Sign out", ActionType: "SignOutAction"}},
+ 0, "CREATE NAVIGATION")
+
+ stmt := signOutMenuStmt(t, "create or modify menu M.Main (\n"+b.String()+");")
+ if !stmt.Items[0].SignOut {
+ t.Errorf("describe emitted %q, which does not parse back as a sign-out item", b.String())
+ }
+}
diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4
index b6d1bb06c6..2725087072 100644
--- a/mdl/grammar/MDLParser.g4
+++ b/mdl/grammar/MDLParser.g4
@@ -337,8 +337,12 @@ navigationClause
// those segments are double-quoted the same way a keyword-colliding name is:
// ICON Atlas_Core.Atlas.home
// ICON Atlas_Core.Atlas."align-center"
+// SIGN_OUT is the third action a menu item can carry. Studio Pro writes it as
+// the same Forms$SignOutClientAction a button uses (measured on ako/TestApp),
+// which is why it sits beside PAGE and MICROFLOW rather than in a syntax of its
+// own.
navMenuItemDef
- : MENU_KW ITEM STRING_LITERAL ((PAGE qualifiedName) | (MICROFLOW qualifiedName))? (ICON qualifiedName)? SEMICOLON?
+ : MENU_KW ITEM STRING_LITERAL ((PAGE qualifiedName) | (MICROFLOW qualifiedName) | SIGN_OUT)? (ICON qualifiedName)? SEMICOLON?
| MENU_KW STRING_LITERAL (ICON qualifiedName)? LPAREN navMenuItemDef* RPAREN SEMICOLON?
;
diff --git a/mdl/types/navigation.go b/mdl/types/navigation.go
index 8fd7041171..4b55c2d277 100644
--- a/mdl/types/navigation.go
+++ b/mdl/types/navigation.go
@@ -107,6 +107,10 @@ type NavMenuItemSpec struct {
Caption string
Page string
Microflow string
+ // SignOut is the third action a menu item can carry. Studio Pro stores it
+ // as the same Forms$SignOutClientAction a button uses, so it needs no
+ // target — which is why it is a flag rather than another name field.
+ SignOut bool
// Icon is a qualified icon-collection name (Atlas_Core.Atlas.home). Empty
// means no icon, which serializes as a null Icon.
Icon string
diff --git a/mdl/visitor/visitor_navigation.go b/mdl/visitor/visitor_navigation.go
index 10de013c7d..460a529ed4 100644
--- a/mdl/visitor/visitor_navigation.go
+++ b/mdl/visitor/visitor_navigation.go
@@ -108,6 +108,11 @@ func buildNavMenuItemDef(ctx parser.INavMenuItemDefContext) ast.NavMenuItemDef {
item.Microflow = &built
next++
}
+ // SIGN_OUT names no target, so it consumes none of the qualifiedName list —
+ // which is why it is read separately rather than as a third switch arm.
+ if c.SIGN_OUT() != nil {
+ item.SignOut = true
+ }
if c.ICON() != nil && len(names) > next {
item.Icon = buildQualifiedName(names[next]).String()
}
diff --git a/sdk/mpr/parser_menu_signout_test.go b/sdk/mpr/parser_menu_signout_test.go
new file mode 100644
index 0000000000..ed85a207dc
--- /dev/null
+++ b/sdk/mpr/parser_menu_signout_test.go
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package mpr
+
+import "testing"
+
+// menuItemRaw builds the minimum a Menus$MenuItem needs to parse: a caption with
+// a real translation (parseNavMenuItem deliberately returns nil for an item with
+// no caption, no page and no children) plus the action under test.
+func menuItemRaw(actionType string) map[string]any {
+ return map[string]any{
+ "Caption": map[string]any{
+ "$Type": "Texts$Text",
+ "Items": []any{
+ int32(3),
+ map[string]any{"$Type": "Texts$Translation", "LanguageCode": "en_US", "Text": "Sign out"},
+ },
+ },
+ "Action": map[string]any{"$Type": actionType},
+ }
+}
+
+// The legacy reader is the other half of reading a sign-out MENU ITEM back.
+// Before this case it fell to the raw-type-name default, so the item was
+// described as a plain `menu item 'x';` and DESCRIBE -> exec turned ako/TestApp's
+// working sign-out entry into a dead one — silently, with mx check clean.
+func TestParseNavMenuItem_SignOut(t *testing.T) {
+ mi := parseNavMenuItem(menuItemRaw("Forms$SignOutClientAction"))
+ if mi == nil {
+ t.Fatal("parseNavMenuItem returned nil")
+ }
+ if mi.ActionType != "SignOutAction" {
+ t.Errorf("ActionType = %q, want SignOutAction — the writers and DESCRIBE key on that string",
+ mi.ActionType)
+ }
+}
+
+// CONTROL: the action types already read must be unchanged, and an unknown one
+// must still fall through to its raw name rather than being absorbed.
+func TestParseNavMenuItem_OtherActionsUnchanged(t *testing.T) {
+ cases := []struct {
+ typeName string
+ want string
+ }{
+ {"Forms$FormAction", "PageAction"},
+ {"Forms$MicroflowAction", "MicroflowAction"},
+ {"Forms$NoAction", "NoAction"},
+ {"Forms$SomethingElseAction", "Forms$SomethingElseAction"},
+ }
+ for _, c := range cases {
+ mi := parseNavMenuItem(menuItemRaw(c.typeName))
+ if mi.ActionType != c.want {
+ t.Errorf("%s -> %q, want %q", c.typeName, mi.ActionType, c.want)
+ }
+ }
+}
diff --git a/sdk/mpr/parser_misc.go b/sdk/mpr/parser_misc.go
index bf687a6215..6eeb70b12a 100644
--- a/sdk/mpr/parser_misc.go
+++ b/sdk/mpr/parser_misc.go
@@ -594,6 +594,11 @@ func parseNavMenuItem(raw map[string]any) *NavMenuItem {
if ms, ok := action["MicroflowSettings"].(map[string]any); ok {
mi.Microflow = extractString(ms["Microflow"])
}
+ case strings.HasSuffix(actionType, "SignOutClientAction"):
+ // Named rather than left to the raw-type-name default: DESCRIBE and
+ // both writers key on "SignOutAction", so a round trip only closes
+ // if the reader produces the name the writer consumes.
+ mi.ActionType = "SignOutAction"
case strings.HasSuffix(actionType, "OpenLinkAction") || strings.HasSuffix(actionType, "OpenLinkClientAction"):
mi.ActionType = "OpenLinkAction"
case strings.HasSuffix(actionType, "NoAction") || strings.HasSuffix(actionType, "NoClientAction"):
diff --git a/sdk/mpr/writer_widgets_action.go b/sdk/mpr/writer_widgets_action.go
index c7ec8942db..1274b331ba 100644
--- a/sdk/mpr/writer_widgets_action.go
+++ b/sdk/mpr/writer_widgets_action.go
@@ -53,6 +53,48 @@ func serializeClientAction(action pages.ClientAction) bson.D {
{Key: "$Type", Value: "Forms$DeleteClientAction"},
{Key: "ClosePage", Value: a.ClosePage},
}
+ case *pages.LinkClientAction:
+ // OPEN_LINK fell through to the default below and was written as
+ // Forms$NoAction, exactly as SIGN_OUT was — the button rendered and did
+ // nothing (CapTrackV2 FINDINGS §10).
+ //
+ // The storage name is Forms$OpenLinkClientAction, NOT the
+ // "Forms$LinkClientAction" the semantic type carries. Pinned against 31
+ // Studio Pro-authored link buttons: five keys, LinkType "Web" in all 31,
+ // address nested as a Forms$StaticOrDynamicString whose AttributeRef is
+ // null for the static form MDL authors.
+ linkType := string(a.LinkType)
+ if linkType == "" {
+ linkType = "Web"
+ }
+ return bson.D{
+ {Key: "$ID", Value: idToBsonBinary(string(a.ID))},
+ {Key: "$Type", Value: "Forms$OpenLinkClientAction"},
+ {Key: "Address", Value: bson.D{
+ {Key: "$ID", Value: idToBsonBinary(generateUUID())},
+ {Key: "$Type", Value: "Forms$StaticOrDynamicString"},
+ {Key: "AttributeRef", Value: nil},
+ {Key: "IsDynamic", Value: false},
+ {Key: "Value", Value: a.Address},
+ }},
+ {Key: "DisabledDuringExecution", Value: true},
+ {Key: "LinkType", Value: linkType},
+ }
+ case *pages.SignOutClientAction:
+ // Until this case existed, SIGN_OUT fell through to the default below
+ // and was written as Forms$NoAction — so the button rendered, said
+ // "Sign out", and did nothing, with `mxcli check`, `exec` and `mx check`
+ // all clean. That made the documented workaround for the modelsdk
+ // engine's refusal ("rerun with MXCLI_ENGINE=legacy") the more dangerous
+ // of the two paths (CapTrackV2 FINDINGS §10).
+ //
+ // One property, pinned against a Studio Pro-authored button (ako/TestApp,
+ // Mendix 11): DisabledDuringExecution, true.
+ return bson.D{
+ {Key: "$ID", Value: idToBsonBinary(string(a.ID))},
+ {Key: "$Type", Value: "Forms$SignOutClientAction"},
+ {Key: "DisabledDuringExecution", Value: true},
+ }
case *pages.CreateObjectClientAction:
// Build EntityRef if entity is specified
var entityRef any
diff --git a/sdk/mpr/writer_widgets_action_test.go b/sdk/mpr/writer_widgets_action_test.go
index 6b6b5de46e..aaafcc660d 100644
--- a/sdk/mpr/writer_widgets_action_test.go
+++ b/sdk/mpr/writer_widgets_action_test.go
@@ -174,3 +174,113 @@ func TestPageClientAction_RequiredFields(t *testing.T) {
t.Error("TitleOverride key missing entirely; Studio Pro writes it as an explicit null")
}
}
+
+// CapTrackV2 FINDINGS §10 — `ACTIONBUTTON … (Action: SIGN_OUT)` was refused by
+// the default modelsdk engine with "client action *pages.SignOutClientAction
+// not yet supported … rerun with MXCLI_ENGINE=legacy".
+//
+// That advice was the more dangerous of the two paths. The legacy writer had no
+// case for the action either, so it fell through to the default below and wrote
+// Forms$NoAction: the button rendered, said "Sign out", and did nothing, with
+// `mxcli check`, `exec` and `mx check` all clean. Measured on Mendix 11.13 —
+// `describe page` came back `actionbutton btnOut (Caption: 'Sign out')`, no
+// action at all, and the stored BSON held Forms$NoAction.
+//
+// The shape is pinned against a Studio Pro-authored sign-out button
+// (ako/TestApp), which is provably Studio Pro's rather than mxcli's: until this
+// change NEITHER engine could emit the type.
+func TestSignOutClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) {
+ doc := serializeClientAction(&pages.SignOutClientAction{
+ BaseElement: model.BaseElement{ID: "action-id"},
+ })
+ if doc == nil {
+ t.Fatal("serializeClientAction returned nil")
+ }
+
+ got := map[string]any{}
+ for _, e := range doc {
+ got[e.Key] = e.Value
+ }
+
+ if got["$Type"] == "Forms$NoAction" {
+ t.Fatal("SIGN_OUT was written as Forms$NoAction — the button renders and does nothing, " +
+ "which check, exec and mx check all report as fine")
+ }
+ if got["$Type"] != "Forms$SignOutClientAction" {
+ t.Errorf("$Type = %v, want Forms$SignOutClientAction", got["$Type"])
+ }
+ if got["DisabledDuringExecution"] != true {
+ t.Errorf("DisabledDuringExecution = %v, want true (the Studio Pro reference's only property)",
+ got["DisabledDuringExecution"])
+ }
+ // The reference carries exactly these three keys and no more. An extra
+ // property is what Studio Pro refuses to open even when mxbuild accepts it.
+ if len(doc) != 3 {
+ t.Errorf("the action has %d keys, want 3 ($ID, $Type, DisabledDuringExecution): %v", len(doc), doc)
+ }
+}
+
+// CONTROL: the quiet default still exists and still yields Forms$NoAction, so
+// the tests here prove something about the actions they name rather than about
+// the fallback having been removed.
+//
+// ShowHomePage is the stand-in: no MDL statement builds one, so it is a
+// semantic type nothing writes. (An earlier draft used LinkClientAction, which
+// stopped being a valid control the moment OPEN_LINK was implemented — a
+// control has to name something still genuinely unhandled.)
+func TestUnhandledClientActionStillFallsBackToNoAction(t *testing.T) {
+ doc := serializeClientAction(&pages.ShowHomePageClientAction{
+ BaseElement: model.BaseElement{ID: "home-id"},
+ })
+ var typeName string
+ for _, e := range doc {
+ if e.Key == "$Type" {
+ typeName, _ = e.Value.(string)
+ }
+ }
+ if typeName != "Forms$NoAction" {
+ t.Errorf("$Type = %q; this control pins the fallback SIGN_OUT and OPEN_LINK used to hit, "+
+ "so the tests above cannot pass for the wrong reason", typeName)
+ }
+}
+
+// OPEN_LINK on the legacy engine, which fell to that same NoAction default.
+// Pinned against the 31 Studio Pro references: five keys, and the address a
+// nested Forms$StaticOrDynamicString whose AttributeRef is null for the static
+// form MDL authors.
+func TestOpenLinkClientAction_IsNotSilentlyDroppedToNoAction(t *testing.T) {
+ doc := serializeClientAction(&pages.LinkClientAction{
+ BaseElement: model.BaseElement{ID: "link-id"},
+ LinkType: pages.LinkTypeWeb,
+ Address: "https://example.com",
+ })
+ got := map[string]any{}
+ for _, e := range doc {
+ got[e.Key] = e.Value
+ }
+ if got["$Type"] != "Forms$OpenLinkClientAction" {
+ t.Fatalf("$Type = %v, want Forms$OpenLinkClientAction (NOT Forms$LinkClientAction, "+
+ "which is the SDK name and not what Mendix stores)", got["$Type"])
+ }
+ if got["LinkType"] != "Web" {
+ t.Errorf("LinkType = %v, want Web", got["LinkType"])
+ }
+ if len(doc) != 5 {
+ t.Errorf("the action has %d keys, want 5: %v", len(doc), doc)
+ }
+ addr, ok := got["Address"].(bson.D)
+ if !ok {
+ t.Fatalf("Address is %T, want a nested document", got["Address"])
+ }
+ a := map[string]any{}
+ for _, e := range addr {
+ a[e.Key] = e.Value
+ }
+ if a["$Type"] != "Forms$StaticOrDynamicString" || a["IsDynamic"] != false ||
+ a["Value"] != "https://example.com" {
+ t.Errorf("Address = %v", addr)
+ }
+ if _, present := a["AttributeRef"]; !present {
+ t.Error("AttributeRef is absent; all 31 references carry it as null")
+ }
+}