Skip to content

Commit ef5c7f6

Browse files
committed
Microflows: add/remove on reference sets; refuse non-owner association writes
`change`/`create` member changes always wrote MemberChange Type=Set, so a reference set could only be replaced, and DESCRIBE rendered a Studio Pro Add/Remove as `Assoc = ...`. New member forms `add <expr> to <Assoc>` and `remove <expr> from <Assoc>` write Type Add/Remove and round-trip through DESCRIBE; they are refused on attributes and on plain references. Writing an association on the non-owner end (e.g. a ReferenceSet owned by its FROM entity, written on the TO entity) passed check and exec and failed mxbuild with CE0854. `mxcli check` now reports it as MDL-ASSOC01 for script-declared associations, and check --references / exec report it for project-stored ones, with an owner-side rewrite as the hint. Verified on a real 11.12.2 app: add/remove microflows exec to mx check 0 errors; the non-owner script (accepted by the old binary) gives 2x CE0854 and is now caught by check. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 57259e8 commit ef5c7f6

21 files changed

Lines changed: 1024 additions & 20 deletions

‎.claude/skills/fix-issue/findings/mdl-executor.jsonl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,3 +680,4 @@
680680
{"area": "mdl/executor", "date": "2026-09-23", "symptom": "`describe page` on a page with several galleries emits MDL that mxcli's own `check --references` rejects: `duplicate widget name 'template1' (used 4 times) — Mendix requires unique widget names per page (CE0495)` (and `filter1` when the galleries have filters). The row/col half of the same report was already fixed; this third name survived it", "cause": "`template`/`filter` inside a gallery are CHILD SLOTS of its definition (gallery.def.json childSlots). `applyChildSlots` builds only the block's children into the slot property and discards the block's name, so DESCRIBE has to synthesise `template1` for every gallery. `checkDuplicateWidgetNames` already exempted the parent's object-list containers but not its child-slot containers", "file": "`mdl/executor/validate_page_context.go` (`unstoredContainerKinds`, was `objectListContainerKinds`; `checkDuplicateWidgetNames`); test `mdl/executor/validate_page_slot_containers_test.go`", "insight": "**When a multi-symptom report is marked fixed, re-run every symptom in it, not the first** — the earlier fix covered `row1`/`col1`/`FullName` and its test never mentioned `template1`, so the issue stayed open with a third name nobody had reproduced. Cheapest proof a name is not stored: author a distinctive one (`template contentZebra`), `grep -rl` it in `mprcontents/` (0 files) and DESCRIBE it back (`template1`). Don't exempt the keyword `template` globally — outside a slot-declaring widget, `template` is `buildTemplateV3`, a `Forms$DivContainer` that DOES store its name; resolve it from the enclosing widget's def (both `ChildSlots` and every mode's), and cover the `container <slotName>` spelling that `applyChildSlots` routes by name. Measured on 11.12.2: four galleries each `template template1` → `mx check` 0 errors", "refs": ["mendixlabs/mxcli#978"], "ce": ["CE0495"]}
681681
{"area":"mdl/executor","date":"2026-09-23","symptom":"\"List views are written with `Editable: false` by mxcli, so every input inside a list view renders disabled — even with `editable: Always` on the text box, and even inside a nested data view.\" Valid BSON, `mxcli check` clean, build succeeds; only the rendered app shows it","cause":"Not a writer bug: false IS Mendix's default for Pages$ListView.editable. The 2026-09-06 fix made an explicit `editable: true` reach the document, but nothing told an author who never wrote it that the list view's read-only context overrides the inputs' own `editable:`","file":"`mdl/executor/validate_listview_editable_inputs.go` (MDL-WIDGET31, hooked in `validate_widgets.go`); test `validate_listview_editable_inputs_test.go`; example `mdl-examples/bug-tests/631-listview-inputs-not-editable.mdl`","insight":"**Measure the default before changing it — and the measurement is cheap.** The issue parked \"default to true\" on a Studio Pro comparison it could not run; `npm pack mendixmodelsdk` and reading the class in `src/gen/pages.js` settles it in a minute: `new PrimitiveProperty(ListView, this, \"editable\", false, …)` and `_initializeDefaultProperties` never sets it, so flipping the default would have made mxcli disagree with every Studio Pro-authored list view. A Studio Pro project confirms it (ako/TestApp, 11.14.0): 30 of 32 list views stored false, none holding an input, and the one with inputs set to true by its author; `describe` of that page checks clean, deleting `Editable: true` from it fires MDL-WIDGET31, and describe -> exec preserves it. The fix is therefore a check for the COMBINATION (not-editable list view + input not marked `editable: Never`). Read editability with the SAME accessor the builder uses (`GetBoolProp`): a quoted `editable: 'true'` parses as a string, the builder writes false, and a rule that read it leniently would stay quiet on a page that renders disabled. Stop the descent at a nested list view — its own Editable governs its inputs and it is visited on its own — but NOT at a nested data view, which the reporter measured does not escape the context","refs":["ako/mxcli#631"],"rules":["MDL-WIDGET31"]}
682682
{"area": "mdl/executor", "date": "2026-09-23", "symptom": "`captionparams` on an action button is accepted by `mxcli check` but not written; a container with `onclick` and a dynamic text does the job. Measured: `actionbutton b (caption: 'Save {1}', captionparams: [{1} = Title])` in a dataview wrote the LITERAL 'Title' (describe: `ContentParams: [{1} = 'Title']`), check --references and mx check both clean; and describe -> exec of any button with params dropped them all -> 4x CE0720 'Place holder index 1 is greater than 0' at mx check.", "cause": "buildButtonV3 carried its own copy of the template-parameter resolver (bare name without `.`/`$` -> quoted literal) instead of the shared buildClientTemplateParams that dynamictext/datagrid columns use; and DESCRIBE emitted a button's params as `ContentParams:`, a key the button builder never read. MDL-WIDGET04's orphan-placeholder check covered dynamictext only, so the round-tripped `{1}` with no parameter reached the build.", "file": "`mdl/executor/cmd_pages_builder_v3_widgets.go` (buildButtonV3 -> buildClientTemplateParams, ContentParams fallback), `mdl/executor/cmd_pages_describe_output.go` (ActionButton emits CaptionParams), `mdl/executor/validate_widgets.go` (validateButtonCaptionPlaceholders)", "insight": "**The report's workaround is the diagnosis**: 'a dynamic text does the job' means the SAME parameter text works on one widget and not another, so diff the two builders before touching grammar or the writer -- the writer was fine (clientTemplateToGen is shared). Two duplicate resolvers had drifted: the shared one gained SourceVariable, toString, association steps and format blocks over a year of fixes; the button copy got none. Do not trust 'check passed' for the literal case -- a literal holding the attribute's name is a valid model, so nothing short of describe (or looking at the rendered button) shows it. The describe->exec round trip is the cheap detector: run it once and the ContentParams/CaptionParams mismatch drops every param and mx check reports CE0720. Keep reading `ContentParams:` on buttons -- scripts dumped before this fix use it. Remaining gap, NOT this bug: `check --references` does not flag an unknown BARE attribute in contentparams/captionparams on either widget (`[{1} = Titel]` passes).", "refs": ["ako/mxcli#632"], "ce": ["CE0720"], "rules": ["MDL-WIDGET04"]}
683+
{"area": "mdl/executor", "date": "2026-09-23", "symptom": "Two defects on reference-set writes in microflows. (a) `change $G (Assoc = $x)` / `create M.E (Assoc = $x)` always wrote MemberChange Type Set: there was no MDL for Add/Remove, so 'add a guest to the group' could only be written as 'replace the group's guests with this one', and DESCRIBE printed a Studio Pro Add/Remove as `Assoc = $x`, so describe -> exec silently turned it into a Set. (b) `$NewGuest = create UserGroups.Guest (Email = $Email, GuestGroup_Guests = $GuestGroup)` against `GuestGroup_Guests from GuestGroup to Guest type ReferenceSet owner Default` passed `mxcli check --references` and exec, then mxbuild failed CE0854 'Association ... is not reachable from entity UserGroups.Guest'.", "cause": "(a) addCreateObjectAction/addChangeObjectAction hard-coded MemberChangeTypeSet and formatAction ignored MemberChange.Type; the writer, reader and MCP backend already carried Type through, so the gap was grammar/AST/builder/describe only. (b) no check compared the written object's entity against the association's owner: --references only resolves the association NAME, which exists.", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (memberChangeList: `add X to M` / `remove X from M`), `mdl/visitor/visitor_microflow_actions.go` (buildMemberChangeList), `mdl/executor/cmd_microflows_builder_actions.go` (memberChangeTypeOf, checkAddRemoveTarget), `mdl/executor/cmd_microflows_format_action.go` (formatMemberChange), `mdl/executor/validate_association_writes.go` (MDL-ASSOC01 static + project halves), `mdl/executor/cmd_microflows_builder_validate.go` (memberWrite hook)", "insight": "An association is stored on its OWNER (ParentPointer = FROM under owner Default; either end under owner Both), and a member change writes on the object being created/changed -- so writability is 'object entity is, or specialises, FROM (or TO when owner Both)'. The existing validation walk (validateStatements) already tracks each variable's entity; hooking one callback into its CREATE/CHANGE cases was enough, with no new flow walker. Split by where the association is declared (script -> static ValidateProgram, no project needed; project -> validateWithContext) so nothing reports twice, and treat an inheritance chain that leaves what is known as UNKNOWN, not 'not a specialisation' -- a false CE0854 on a subclass of the owner would be worse than the miss. When a round trip loses a stored enum like MemberChange.Type, check the describer before the writer: here the writer was right all along.", "refs": [], "ce": ["CE0854"], "rules": ["MDL-ASSOC01"]}

‎.claude/skills/mendix/cheatsheet-errors/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ Run with `-p` for the fullest coverage.
265265
| CE0104 | Action activity is unreachable | Code after RETURN |
266266
| CE0105 | Must end with end event | Missing RETURN |
267267
| CE0117 | Error in expression | Unqualified association path |
268+
| CE0854 | Association 'M.A' is not reachable from entity 'M.E' | The association is written (in `create`/`change`) on the non-owner end — write it on the FROM entity (`change $Owner (add $obj to M.A)`), or declare it `owner Both`. `check` flags MDL-ASSOC01 |
268269
| CE1571 | No argument selected for parameter | A microflow/nanoflow call with a parameter nothing fills — as a `datasource:` **or** an `action:`. Give it an argument (`action: nanoflow M.NF($P = $value)`), or nest the widget in a data container of the parameter's type. `check -p` reports both |
269270
| CE1571 | …in a control bar | A control bar is **not** row-scoped, so the grid's row does not fill it: pass the grid's selection (`$dgOrders`, with `Selection:` set) or move the widget into a column. `$currentObject` there is MDL-BUTTON01 |
270271
| CE1834 | The 'Page' property is required | Workflow user task without a `page` — `check` flags MDL-WF01 |

‎.claude/skills/mendix/write-microflows/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ Before executing a microflow script, verify:
535535
- [ ] No code appears after `return` statements
536536
- [ ] Division uses `div` operator (not `/`)
537537
- [ ] All entity/association names are fully qualified
538+
- [ ] **Associations are written from the owner side** (FROM entity unless `owner Both`) — the other end is CE0854 (MDL-ASSOC01); use `add … to` / `remove … from` to change a reference set without replacing it ([details](reference/data-operations.md#writing-associations-owner-side-and-addremove-on-a-reference-set))
538539
- [ ] **CALL MICROFLOW parameter names exactly match target signature** (use `describe microflow` to verify)
539540
- [ ] Microflow ends with `/` separator
540541
- [ ] Parameters start with `$` prefix

‎.claude/skills/mendix/write-microflows/reference/data-operations.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,37 @@ end if;
386386
-- Combine with operators
387387
set $TotalPrice = $Product/Price * $Quantity;
388388
```
389+
390+
## Writing Associations: Owner Side, and Add/Remove on a Reference Set
391+
392+
A member change on an association writes the reference **on the object being
393+
created or changed**, and Mendix stores an association only on its **owner**:
394+
the FROM entity under `owner Default`, or either end under `owner Both`. For
395+
`create association M.GuestGroup_Guests from M.GuestGroup to M.Guest type ReferenceSet`:
396+
397+
```mdl
398+
-- ❌ CE0854 "Association 'M.GuestGroup_Guests' is not reachable from entity 'M.Guest'"
399+
$NewGuest = create M.Guest (Email = $Email, GuestGroup_Guests = $GuestGroup);
400+
change $Guest (GuestGroup_Guests = $Remaining);
401+
402+
-- ✅ write it from the owner (GuestGroup)
403+
$NewGuest = create M.Guest (Email = $Email);
404+
change $GuestGroup (add $NewGuest to M.GuestGroup_Guests);
405+
change $OldGroup (remove $Guest from M.GuestGroup_Guests);
406+
```
407+
408+
`mxcli check` reports the non-owner write as **MDL-ASSOC01** when the script
409+
declares the association, and `check --references` / `exec` report it against
410+
associations stored in the project.
411+
412+
The three member-change forms map onto Mendix's member change **Type**:
413+
414+
| MDL | Type | Effect |
415+
|-----|------|--------|
416+
| `Assoc = $x` | Set | Replaces the reference — for a reference set, **the whole set** |
417+
| `add $x to Assoc` | Add | Adds the object (or every object in a list) to the set |
418+
| `remove $x from Assoc` | Remove | Removes the object (or list) from the set |
419+
420+
`add`/`remove` apply to **reference sets only** — on a plain reference, or on
421+
an attribute, they are refused. Use `Assoc = $x` when you mean "replace":
422+
`change $GuestGroup (GuestGroup_Guests = $NewGuest)` leaves one guest in the group.

‎cmd/mxcli/syntax/features_microflow.go‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,11 @@ func init() {
309309
"create object", "change object", "commit", "rollback",
310310
"delete", "save", "persist", "modify object",
311311
"with events", "refresh", "commit flag", "without events",
312+
"add to reference set", "remove from reference set", "reference set", "member change type",
312313
},
313314
Syntax: "$Obj = CREATE Module.Entity (Attr = value) [COMMIT [WITHOUT EVENTS]] [REFRESH];\n" +
314315
"CHANGE $Obj (Attr = value) [COMMIT [WITHOUT EVENTS]] [REFRESH];\n" +
316+
"CHANGE $Owner (ADD $Obj TO Module.Assoc, REMOVE $Other FROM Module.Assoc);\n" +
315317
"COMMIT $Obj [WITHOUT EVENTS] [REFRESH];\n" +
316318
"DELETE $Obj [REFRESH];\n" +
317319
"ROLLBACK $Obj [REFRESH];\n\n" +
@@ -328,8 +330,17 @@ func init() {
328330
"-- COMMIT is the one whose default is ON, so WITHOUT EVENTS is the form\n" +
329331
"-- that changes anything; WITH EVENTS parses and means the default. The\n" +
330332
"-- COMMIT modifier on CREATE/CHANGE is the activity's Commit setting\n" +
331-
"-- (omitted = No), not the standalone COMMIT $Obj activity.",
332-
Example: "$NewOrder = CREATE MyModule.Order (\n OrderNumber = 'ORD-001',\n Quantity = $Quantity,\n CreateDate = [%CurrentDateTime%]\n) COMMIT;\n\nCHANGE $NewOrder (MyModule.Order_Customer = $Customer) COMMIT REFRESH;\nCHANGE $Draft (Status = 'Imported') COMMIT WITHOUT EVENTS;\n\nCOMMIT $NewOrder; -- runs the commit event handlers\nCOMMIT $Staging WITHOUT EVENTS; -- bulk import: skip them deliberately\nCOMMIT $NewOrder REFRESH; -- and repaint it on the open page\n\nDELETE $OldOrder REFRESH;\nROLLBACK $DraftOrder;",
333+
"-- (omitted = No), not the standalone COMMIT $Obj activity.\n" +
334+
"--\n" +
335+
"-- A member is changed in one of three ways (Mendix's member change Type):\n" +
336+
"-- Assoc = $x Set: replace the reference / the whole set\n" +
337+
"-- ADD $x TO Assoc Add: add object(s) to a reference SET\n" +
338+
"-- REMOVE $x FROM Assoc Remove: take object(s) out of a reference SET\n" +
339+
"-- ADD/REMOVE take an object or a list, and apply to reference sets only.\n" +
340+
"-- An association is written on its OWNER - the FROM entity under\n" +
341+
"-- `owner Default`, either end under `owner Both`. Writing it from the other\n" +
342+
"-- end is CE0854; mxcli check reports it as MDL-ASSOC01.",
343+
Example: "$NewOrder = CREATE MyModule.Order (\n OrderNumber = 'ORD-001',\n Quantity = $Quantity,\n CreateDate = [%CurrentDateTime%]\n) COMMIT;\n\nCHANGE $NewOrder (MyModule.Order_Customer = $Customer) COMMIT REFRESH;\nCHANGE $Draft (Status = 'Imported') COMMIT WITHOUT EVENTS;\n\nCOMMIT $NewOrder; -- runs the commit event handlers\nCOMMIT $Staging WITHOUT EVENTS; -- bulk import: skip them deliberately\nCOMMIT $NewOrder REFRESH; -- and repaint it on the open page\n\nDELETE $OldOrder REFRESH;\nROLLBACK $DraftOrder;\n\n-- reference set, owned by GuestGroup (the FROM end)\nCHANGE $GuestGroup (ADD $NewGuest TO UserGroups.GuestGroup_Guests);\nCHANGE $GuestGroup (REMOVE $Guest FROM UserGroups.GuestGroup_Guests);",
333344
SeeAlso: []string{"microflow.retrieve", "microflow.variables"},
334345
})
335346

‎docs-site/src/language/activity-types.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ CHANGE $Order (
3131

3232
Multiple attributes can be changed in a single `CHANGE` statement.
3333

34+
#### Associations: owner side, and adding to a reference set
35+
36+
An association member is written on the object being created or changed, and
37+
Mendix stores an association on its **owner** — the `FROM` entity under
38+
`OWNER Default`, either end under `OWNER Both`. Writing it from the other end is
39+
rejected by the build with **CE0854** ("not reachable from entity"), and
40+
`mxcli check` reports it as **MDL-ASSOC01**.
41+
42+
For a reference set, `Assoc = $x` **replaces** the whole set. To add objects to
43+
it, or take them out, use `ADD … TO` and `REMOVE … FROM` (Mendix's member change
44+
types Add and Remove). Both take an object or a list, and apply to reference
45+
sets only:
46+
47+
```sql
48+
-- GuestGroup_Guests runs FROM GuestGroup TO Guest, so GuestGroup owns it
49+
CHANGE $GuestGroup (ADD $NewGuest TO UserGroups.GuestGroup_Guests);
50+
CHANGE $OldGroup (REMOVE $Guest FROM UserGroups.GuestGroup_Guests);
51+
$Group = CREATE UserGroups.GuestGroup (Name = 'VIP', ADD $Guests TO UserGroups.GuestGroup_Guests);
52+
```
53+
3454
### COMMIT
3555

3656
Persists an object (or its changes) to the database:

‎docs/01-project/MDL_QUICK_REFERENCE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ it is for pages.
507507
| Assignment | `set $Var = expression;` | Variable must be declared first |
508508
| Create object | `$Var = create Module.Entity (attr = value) [commit [without events]] [refresh];` | `commit` = Commit Yes, `commit without events` = YesWithoutEvents; omitted = No (the default) |
509509
| Change object | `change $entity (attr = value) [commit [without events]] [refresh];` | `commit` as above, before `refresh`; `refresh` updates the changed object in the client |
510+
| Add to / remove from a reference set | `change $owner (add $obj to Module.Assoc, remove $other from Module.Assoc);` | Mendix's member change type Add/Remove (`assoc = $x` is Set, which **replaces** the set). Also in `create … (…)`. Takes an object or a list; reference sets only. An association is written on its **owner** (FROM entity under `owner Default`, either end under `owner Both`) — the other end is CE0854, reported by `mxcli check` as MDL-ASSOC01 |
510511
| Commit | `commit $entity [without events] [refresh];` | **Omitted = with events**, matching Studio Pro's default. `without events` is the deviation and the only form that changes the stored value; `with events` still parses and means the default |
511512
| Delete | `delete $entity [refresh];` | |
512513
| Rollback | `rollback $entity [refresh];` | Reverts uncommitted changes |
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- ============================================================================
2+
-- Association written from its NON-OWNER end — must FAIL `mxcli check`
3+
-- ============================================================================
4+
--
5+
-- GuestGroup_Guests runs FROM GuestGroup TO Guest, owner Default, so only a
6+
-- GuestGroup can write it. Both microflows below write it on a Guest, which
7+
-- mxbuild rejects:
8+
--
9+
-- CE0854 Association 'RefSetNonOwner.GuestGroup_Guests' is not reachable
10+
-- from entity 'RefSetNonOwner.Guest'.
11+
--
12+
-- Before MDL-ASSOC01, `mxcli check --references` passed this (the association
13+
-- NAME resolves) and `exec` wrote it as authored. The fix is to write it from
14+
-- the owner — `change $GuestGroup (add $NewGuest to ...GuestGroup_Guests)` —
15+
-- as in mdl-examples/doctype-tests/microflow-reference-set-add-remove.mdl.
16+
-- ============================================================================
17+
18+
CREATE MODULE RefSetNonOwner;
19+
20+
CREATE OR MODIFY PERSISTENT ENTITY RefSetNonOwner.GuestGroup ( Name: String(100) );
21+
22+
CREATE OR MODIFY PERSISTENT ENTITY RefSetNonOwner.Guest ( Email: String(200) );
23+
24+
CREATE OR MODIFY ASSOCIATION RefSetNonOwner.GuestGroup_Guests
25+
FROM RefSetNonOwner.GuestGroup TO RefSetNonOwner.Guest TYPE ReferenceSet OWNER Default;
26+
27+
CREATE OR MODIFY MICROFLOW RefSetNonOwner.ACT_AddGuest ($GuestGroup: RefSetNonOwner.GuestGroup, $Email: String)
28+
BEGIN
29+
$NewGuest = CREATE RefSetNonOwner.Guest (Email = $Email, GuestGroup_Guests = $GuestGroup) COMMIT;
30+
END;
31+
/
32+
33+
CREATE OR MODIFY MICROFLOW RefSetNonOwner.ACT_MoveGuest ($Guest: RefSetNonOwner.Guest, $Remaining: RefSetNonOwner.GuestGroup)
34+
BEGIN
35+
CHANGE $Guest (GuestGroup_Guests = $Remaining) COMMIT;
36+
END;
37+
/

0 commit comments

Comments
 (0)