Skip to content

Sync ako/mxcli: sort over associations, page reference validation, rewrite-churn fixes (31 commits) - #1156

Merged
ako merged 31 commits into
mendixlabs:mainfrom
ako:main
Sep 22, 2026
Merged

ako merged 31 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Syncs ako/mxcli:main into mendixlabs/mxcli:main — 31 commits, 15 of them non-merge, since a909abc7 (docs(changelog): release v0.23.0). The fork is 0 behind.

Grouped by theme; each bullet is one fix with the measurement that settled it.

Sorting a retrieve over an association — #1152

  • Resolve the hop across ancestors and modules (d614dbf). retrieve … sort by System.Language.Code asc — MDL mxcli describe had just emitted — passed check and was refused by exec with "does not belong to entity 'Administration.Account'". The hop is System.User_Language, declared on the ancestor System.User and stored in the System module; the resolver searched one domain model, matched only associations whose parent was the retrieved entity itself, and qualified with the wrong module. Now a generalization-chain walk. Control: a build that derives the hop and does not write it is mxbuild CE7247.
  • Let a sort column name the association it navigates (fe5a740). Inference cannot be made correct where two associations reach one entity. Measured on 11.12.3 with Order_ShipTo/Order_BillTo (both Order → Address): a microflow sorting by the billing address came back from describe → exec sorting by the shipping one, mx check 0 errors both sides. sort by Sales.Order_BillTo/Sales.Address.City asc now says which — in a microflow retrieve and a page datasource alike. Two readers were dropping the stored hop, not one.

Pages — references that nothing resolved, properties that nothing bound

  • Validate the widgets inside a placeholder block (79186ce0). A page's widgets live in two AST fields and all three validators were given only the bare body, so every reference inside placeholder X { … } — microflow, nanoflow, page, snippet, entity, image — was validated by nothing. Not an edge shape: it is what mxcli's own skills, bug-tests and DESCRIBE output write.
  • Resolve the image a static or dynamic image widget names (f5961faa, SelectionHelper custom states cannot include StaticImageViewer widgets via MDL #1149). widgetRefCollector keyed the image reference on widget type, so staticimage's Image and dynamicimage's DefaultImage resolved to nothing — check --references passed and the build failed CE1613.
  • Bind a pluggable widget's textTemplate to an attribute (95c1841f, Consolidate DataGrid builder paths: datagrid keyword vs pluggablewidget engine #575). A headerCaption: 'Name' rendered the literal word "Name" on every node, and the <Name>Params companion the report reached for did not exist at widget level.
  • Read text templates in the generic pluggable DESCRIBE path (839e6c28, Consolidate DataGrid builder paths: datagrid keyword vs pluggablewidget engine #575). Every pluggable widget outside the list of nine went through a path reading AttributeRef and PrimitiveValue only, so TreeNode's and Timeline's text templates were absent from describe page and describe → exec dropped the caption.

Rewrites that silently dropped or churned

  • Keep validation rules create or modify entity cannot spell (240c5aa6). The entity body authors Required and Unique only; RegEx and Range come from a separate statement and were listed among the fields the rewrite is authoritative about, so every rewrite deleted them. The visible symptom was churn, because the scripts that hit it re-create the rule next statement — the loss is silent.
  • Reconcile a unit re-inserted under the ID of one just deleted (3ed95a43). Several create or modify handlers are delete + create under the preserved unit ID, and canon.Reconcile lives in updateUnit, so those writes never reached it. Two identical runs of one create or modify rest client differed in 143 bytes, every one an element $ID. This is CLAUDE.md's "a new write path means wiring it to canon.Reconcile" in the shape that hides — the new path is insertUnit reached by way of deleteUnit.
  • Write a view entity's OQL document in place (65d79818). Changing only the OQL printed Unchanged view entity while describe entity showed the new query stored.
  • Regression case and finding for re-exec identity stability (568f7be0). One idempotent script covering both instances of Re-exec is not identity-stable: nested GUIDs are re-minted, and three unit types are replaced under a new unit GUID ako/mxcli#556; the control is a binary with both fixes stashed out — 3 changed files on every re-run, mx check 0 errors each time, which is why the version-control diff was the only signal this class ever produced.

System-module metadata

  • Measure the System module's String lengths (616e2d23, fix #583: parse StringAttributeType.Length across BSON numeric widths #584). meta.SystemAttrDef declared a Length and none of the 115 String attributes populated it, so every System string was built unlimited and every length comparison was made against 0. A view entity selecting u.Name from System.User could not be declared in any way that both passed check and built.
  • Do not judge a pass-through length mxcli does not know (1237b5cd). MDL031 refused every declaration of a column taken from a System.* string attribute — including the one that builds — and suggested the one that does not. System.User.Name is String(100), not the 200 both the field report and the issue text assumed.

Other

  • Report the members a script removes from the project — MDL087 (71851f74, CREATE OR MODIFY ENTITY drops attributes added by a later ALTER without saying so ako/mxcli#562). create or modify entity rebuilds from the statement, so an attribute a later slice added is deleted when the earlier slice is re-run alone; the loss surfaced two slices later as CE1613 on a page, naming the page and never the script. Now caught by check … --references, the one command that runs before anything is written.
  • Manage workflow groups via MDL (832e9c80, feat: Manage Workflow Groups via MDL #272). alter settings workflows add|modify|remove group '…' and show workflow groups — a scripted project setup no longer has to stop and open Studio Pro to define the buckets a user task's group targeting selects from.
  • Session-auth OData needs X-Csrf-Token (10d6892b). The mendix-vega-charts skill pack said the session cookie was sufficient; it is not, and Vega reads the 401 body as an empty dataset, so a chart drew axes and a full legend with zero points and no error. Measured on 11.14.0, one URL, four requests: cookie only → 401, cookie + X-Csrf-Token → 200

claude and others added 30 commits September 21, 2026 08:12
A page's widgets live in two AST fields. `Widgets` is the bare body;
content addressed to a named layout placeholder is held apart in
`Placeholders` (#532). validate.go passed `s.Widgets` alone to all three
page validators, so every reference inside a `placeholder X { … }` block
— microflow, nanoflow, page, snippet, entity, image — was validated by
nothing.

That is not an edge shape: `placeholder Main { … }` is what mxcli's own
skills, its bug-test examples and its DESCRIBE output write. Measured on
a blank Mendix 11.14.0 project, the same button in the two positions:

    placeholder Main { actionbutton btn (Action: MICROFLOW Mod.NoSuch) }
                                  -> ✓ All references valid
    actionbutton btn (Action: MICROFLOW Mod.NoSuch)   (bare body)
                                  -> microflow not found: Mod.NoSuch

It is the third copy of one walk — validateIconRefs (mendixlabs#1008)
and forEachWidget each grew the placeholder arm separately, both with a
comment saying a missed walk is silent in both directions — so the roots
are collected once in allPageWidgets rather than added to a fourth
walker later.

Control: with allPageWidgets reduced to `return s.Widgets`, the
placeholder test fails with the reported symptom (`got []`) and the
bare-body control still passes, so the test detects the bug rather than
merely agreeing with the fix. A reference that resolves stays silent,
which is the direction a widened walk is most likely to get wrong.

Refs: mendixlabs#1149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExDoCXFBfqq3R6j2qKx2Kf
mendixlabs#1149 reports that a Selection helper's custom states
cannot hold a StaticImageViewer. The authoring half landed with mendixlabs#1057 —
on main the reporter's page describes back with three `staticimage`
lines and their `Image:` clauses, and re-running the description reports
`Unchanged page`. What was still missing is the rest of the request, "a
reference to an image from an image collection": the name could be
written and nothing resolved it.

widgetRefCollector keyed the image reference on the widget TYPE, so the
pluggable `image` widget was collected and the two widgets mendixlabs#1057 had
just given the same property were not — `staticimage`'s `Image` and
`dynamicimage`'s `DefaultImage`. Measured on a blank Mendix 11.14.0
project:

    mxcli check sh.mdl -p app.mpr --references -> Check passed!
    mxcli exec  sh.mdl -p app.mpr             -> Created page …
    mx check -> [error] [CE1613] "The selected image
       'Atlas_UI_Resources.Atlas_Icons.checkbox_checked' no longer
       exists."  at Static image 'imgAll'   (x3, one per state)

Fixed, the same script reports all three at check time. With references
that resolve, `check` passes and mxbuild reports no CE1613 — only
CE0582, Mendix's own React-client deprecation of static images, which
mendixlabs#1057's bug test already records as not an mxcli defect.

It is a table now (imageRefProps), not a condition on one type name: the
set grew and the condition did not, and `validate_widgets.go` already
accepted these keys while DESCRIBE already emitted them, so only the
resolver disagreed. Adding a widget that names an image means adding a
row.

Control: with the table reduced to the pluggable widget alone, the three
symptom tests fail with the reported symptom (the reference accepted,
`got 0 errors`) while both controls — a resolvable image under every
spelling, and an unrelated `Image` property on a widget that is not in
the table — still pass.

Closes mendixlabs#1149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExDoCXFBfqq3R6j2qKx2Kf
Workflow Groups (App Settings > Workflows > Groups) could not be created,
listed or modified through mxcli, so a scripted project setup had to stop and
open Studio Pro to define the buckets a user task's group targeting selects
from. They are project settings — peers of UserEntity and
DefaultTaskParallelism — and were missing from ALTER SETTINGS WORKFLOWS and
DESCRIBE SETTINGS. Closes mendixlabs#272.

    alter settings workflows add [or modify] group 'Approvers' (Description: '…');
    alter settings workflows modify group 'Approvers' (Description: '…');
    alter settings workflows remove group 'Approvers';
    show workflow groups;

The four verbs match the LANGUAGE forms rather than the issue's proposed
ADD/ALTER/DROP, because the two clauses share a grammar rule and the same
reasoning: a group is identified by its name alone, so it is addressed the way
a language's code is. ADD OR MODIFY is the upsert DESCRIBE SETTINGS emits, so a
described project replays onto itself quietly.

Pinned against real Mendix documents rather than a guess:

- Settings$WorkflowGroup stores Name and Description and nothing else
  (modelsdk/gen, generated/metamodel and mendixmodelsdk 4.115.0 all agree), so
  Description is the only option and an unknown key is refused rather than
  written into a property the type does not declare.
- The Groups typed-array marker is 2, not the 3 every other settings child list
  uses — measured on a blank 11.13.0 project, whose empty list is `Groups: [2]`.
- The version floor is 11.2.0, not the 11.6 the issue quotes: both the type and
  the groups property read `introduced: "11.2.0"` in the Model SDK's
  StructureVersionInfo, and an 11.1.0 project's workflows settings part has no
  Groups key at all while an 11.13.0 one has it. Release notes date the
  feature's GA; the metamodel decides whether the document loads.

Two things the implementation turns on:

- The write is an overlay on the preserved raw part, so adding Groups to the
  model and the read path is not enough. Without the overlay call the executor
  reports "Added workflow group: Auditors (3 group(s))" and writes nothing, with
  mx check clean either way — the shape the enabled-language list already went
  through. A stored element that did not decode is refused rather than dropped
  (ADR-0005).
- A group's element $ID is its identity in the runtime database. Booting the app
  materialises one system$workflowgroup row per entry whose modelguid is that
  $ID read as a .NET GUID (measured on 11.13.0: stored bytes
  7c5fc4cf05c3394fa6718a3d4603e9a5, row cfc45f7c-c305-4f39-a671-8a3d4603e9a5),
  so a description edit updates the row in place instead of orphaning every
  group membership under a perfectly valid model.

Verified end to end on Mendix 11.13.0: exec writes the document, mx check is 0
errors, the booted runtime materialises both groups, a replay is elided (no unit
mtime moves) and describe -> exec round-trips including quote escaping.

Controls recorded: deleting the one overlay call reproduces the silent no-write
verbatim; reverting the option key from identifierOrKeyword to IDENTIFIER fails
the visitor test with "mismatched input 'Description' expecting IDENTIFIER" —
Description is an MDL keyword, so the feature's only option was a parse error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDVJRtXZmaXQGENThpcmDV
fix(pages): resolve image references, and validate what is inside a placeholder block
…ules

`retrieve $AccountList from Administration.Account sort by
System.Language.Code asc;` — MDL that `mxcli describe` had just emitted —
passed `mxcli check` and was refused by `mxcli exec`:

  sort by attribute 'System.Language.Code' does not belong to entity
  'Administration.Account'

Mendix stores a sort over an association as an AttributeRef naming the far
entity plus an EntityRef of EntityRefSteps. DESCRIBE emits only the
attribute's qualified name — MDL has no spelling for the hop — so replaying
a described retrieve has to re-derive the step.

inferSortEntityRefSteps made three assumptions that hold only when the hop
starts on the retrieved entity in its own module: it searched one domain
model (the retrieved entity's), matched only associations whose parent was
the retrieved entity itself, and qualified the association it found with the
retrieved entity's module. Administration.Account reaches System.Language
through System.User_Language, which is declared on the ancestor System.User
and stored in the System module — all three are wrong there.

It is now a generalization-chain walk that looks each ancestor up in its own
module and qualifies the association with the module storing it. The
destination end is matched with entityIsSubtypeOf rather than by equality,
since an association may point at a specialization of the entity that
declares the attribute.

Measured on a blank 11.12.3 app:

  fixed         exec ✓, describe → exec is a fixed point, mx check 0 errors,
                stored hop reads System.User_Language → System.Language
  fix reverted  check ✓, exec fails with the reported message
  hop dropped   exec ✓, mx check CE7247 "Cannot sort on attribute
                'System.Language.Code'. Attribute 'System.Language.Code' is
                not an attribute of entity 'Administration.Account'."

An attribute of an entity that is neither in the chain nor one hop away is
still refused, in the unit tests and in the real project.

Known residue, recorded in the finding and in the code: where several
associations reach one entity, DESCRIBE cannot say which was stored and the
nearest ancestor's first hop wins. Spelling the hop needs grammar.

Fixes mendixlabs#1152

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuVi6vytSqoqAtZK8YzvD2
The inference fix restored the reported script but left the round trip
lossy. `describe` emitted only the sort attribute's qualified name, so
replaying it had to re-derive the hop — and where two associations reach the
same entity there is nothing to derive it from.

Measured on 11.12.3, with Order_ShipTo and Order_BillTo both Order → Address
(an ordinary shape, not a corner case). A microflow sorting by the BILLING
address, run through describe → exec:

  stored hop before:  MyFirstModule.Order_BillTo
  describe emits:     sort by MyFirstModule.Address.City asc
  stored hop after:   MyFirstModule.Order_ShipTo

mx check is 0 errors on both sides. The app sorts by the wrong address and
nothing in the toolchain says so.

A sort column now takes an association path — one `/` per hop, the last
segment names the attribute — in a microflow retrieve and in a page
datasource alike:

  retrieve $Orders from Sales.Order
      sort by Sales.Order_BillTo/Sales.Address.City asc;

  listview lv (datasource: database from Sales.Order
      sort by Sales.Order_BillTo/City asc)

`qualifiedName (SLASH qualifiedName)*` is the shape MDLCatalog.g4 already
uses for Association/Entity, and `Assoc/Attr` is how DataGrid2 columns and
dynamictext params navigate, so this adds no new idiom. Inference stays as
the fallback: every script written before keeps working.

Two reads were missing, not one. sortItemsFromRaw read AttributeRef.Attribute
and skipped AttributeRef.EntityRef, and the page reader did the same — so the
hop was written and never read back, and DESCRIBE could not have emitted it
even with a spelling in hand. Fixing only the microflow half would have
shipped a describer that emits the path for microflows and silently drops it
for pages.

A hop that does not resolve, or does not start at the entity in hand, is
refused rather than written: a step with an empty DestinationEntity makes the
project unopenable rather than merely wrong.

Measured, both halves, with controls:

  fixed          mx check 0 errors; describe → exec replays every sort to
                 "Unchanged" (the write elided — the rebuild is identical)
  hops ignored   the named Order_BillTo is stored as Order_ShipTo
  hop dropped,   mx check CE7247 "Cannot sort on attribute
  microflow      'System.Language.Code' …"
  hop dropped,   mx check CE7247 "… is not an attribute of entity
  page           'MyFirstModule.Order'." at Sort bar of list view 'lvOrders'

Refs mendixlabs#1152

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuVi6vytSqoqAtZK8YzvD2
feat(settings): manage workflow groups via MDL
… did not

The mendix-vega-charts skill pack stated that "same-origin requests carry
the session cookie, so an endpoint authenticated by session is reachable
from a chart on a page of the same app without any token handling". The
cookie does go; it is not sufficient. A URL-fed Vega-Lite spec therefore
drew axes and a full legend with zero data points and no error, because
Vega reads the 401 body as an empty dataset.

Measured on a freshly built 11.14.0 app, one URL, four requests:

    no credentials                    401
    session cookie only               401
    session cookie + X-Csrf-Token     200, correct payload
    basic auth                        200, correct payload

Rows 2 and 3 differ in exactly one header, and the 401 body is byte-identical
to the one reported. Through vega's own loader against that running app:
0 marks / 3 axes / no error without the token, 1 mark with it — the reported
symptom, reproduced end to end.

The docs fix is the reported ask, but a doc saying "the widget must supply
the header" over a widget that does not is half a fix, so the pack's widget
now installs a loader that attaches the token — to same-origin requests
ONLY. The token authenticates this session against this app, and the
same-origin decision resolves the URI against the page rather than testing
it for a scheme: a protocol-relative //elsewhere.example/rows.json carries
no scheme, so a scheme test reads it as relative and hands another host a
working session credential.

  - widget/src/csrf.ts: isSameOrigin / readCsrfToken / withCsrfHeader,
    pure and non-mutating (vega reuses one options object across a spec's
    fetches, so writing a header into it would leak the token to later URLs)
  - widget/test/csrf.test.ts: 9 cases, node's own runner over .ts, no deps
  - make check-skill-pack-js + a CI step: nothing else in this repo compiles
    a pack's widget, so logic shipped there was ungated
  - two guards on the vendored pack in cmd/mxcli/skillpacks_test.go: a pack
    documenting a relative-URL data source must mention X-Csrf-Token, and a
    widget attaching the header must resolve the uri with new URL()

Controls, each run with the fix reverted:
  - guard stubbed to pre-fix behaviour: "X-Csrf-Token missing: Mendix
    answers this request 401", 4 of 9 cases fail
  - the issue's own suggested regex in place of the origin resolution:
    4 of 9 fail, including the protocol-relative leak
  - both Go guards reverted: both fail, naming the file

Also verified: the modified widget typechecks clean under --strict against
the real vega / vega-embed / react types.

Fixes #574

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwJzSGaDx7vzTE2g4QN7kz
…087)

#562. `create or modify entity` rebuilds the entity from the
statement, so an attribute a LATER script added is deleted when the earlier
script is re-run on its own. The reporter hit it with a calculated attribute:
the entity is created in 01-domain-core.mdl and the attribute added in
03-logic.mdl, because the microflow it calculates by does not exist until
then. Re-running slice 01 alone removed it, and the loss surfaced two slices
later as CE1613 on a page — an error naming the page, never the script.

The issue asks for two things. exec's warning already shipped (findings #24)
and does fire; measured on a real 11.6.6 project re-running the reporter's
slice 01, it prints the attribute by name, so the report predates it. The
second ask did not exist: `mxcli check … -p app.mpr --references` printed
"Check passed!" and said nothing, which is the one command that runs BEFORE
anything is written.

CheckEntityMemberDrops joins the catalog-backed tier of `check` as MDL087, a
warning ("modify to this shape" is a legitimate intent; the defect was the
silence, and the run still exits 0). It is deliberately NOT the same
computation as the exec-time warning:

  - NET over the whole script, not per-statement. A script that rebuilds an
    entity and then `alter entity … add attribute`s the members back loses
    nothing — and that is the idiomatic full-script order, so a per-statement
    port would warn on every correct script.
  - Intent is tracked, not inferred. `drop attribute`, `rename attribute` and
    `drop entity` produce the same before/after diff as the accident.

droppedEntityMembers now delegates to the shared entityMemberSet comparison
rather than keeping a second hand-written diff: the audit system fields and an
omitted `extends` are covered once, and an audit pseudo-type is a flag rather
than an attribute on both sides. Reporting order is the entity's own attribute
order, so the exec warning's output is unchanged.

Controls recorded, both directions:
  - guard stubbed → 4 of 10 tests fail with the reported symptom
    ("got 0 violation(s)"), the silence tests still pass
  - net/intent logic removed → TestMDL087_ExplicitRemovalIsSilent fails on 3
    of 4 spellings while the positive test still passes, so the positive test
    alone proves nothing

Verified end to end on a real project: silent before, MDL087 after, with the
concatenated slices and an explicit `drop attribute` both silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WGX3LgaNzHcnpAoHRzRZx
A textTemplate property took literal text only, so a caption written as

  treenode tn (headerType: 'text', headerCaption: 'Name', ...)

passed check, exec and `mx check` and rendered the word "Name" on every
node. The companion the report reached for did not exist:

  x widget `tn` (treenode) has no property `headerCaptionParams`
    -- did you mean `headerCaption`? [MDL-WIDGET01]

`<Name>Params` was already the convention an object-list ITEM used for its
text templates (mendixlabs#956); it stopped at the item boundary. At the widget level
the engine read parameters from one place, the widget-wide `contentparams:`
-- a single list for every template on the widget, which cannot say "this
caption binds Name and that one binds Remarks". Three gaps:

1. resolveMapping's "TextTemplate" case consulted only GetContentParams().
2. A texttemplate mapping addressed by its def.json SOURCE name (an Image's
   `ImageUrl:`, schema key `imageUrl`) fell through to the default branch,
   which set no parameters at all -- not even contentparams, so mendixlabs#928's fix
   only ever covered the schema-key spelling.
3. allowedWidgetProperties did not know the companion, so writing it was an
   MDL-WIDGET01 error and exec refused the whole script.

Each text template now takes its own `<Name>Params`, under whichever
spelling the template itself was written (schema key, alias, or source
name); `contentparams:` stays the fallback and `'{AttrName}'` is
untouched. An orphaned companion -- parameters beside a literal caption --
is reported as MDL-WIDGET21 instead of being dropped in silence, and
DESCRIBE emits the companion so describe -> exec keeps the binding
(it round-tripped to a bare `{1}`, which re-executes into CE0720).

Measured on a blank Mendix 11.14.0 project, mxbuild 11.14.0, with the
built-in pluggable Image standing in for the TreeNode -- it has the same
shape (two text templates) and needs no download:

  pre-fix   check -> MDL-WIDGET01 x4 on imageUrlParams /
                     alternativeTextParams and their source-name spellings;
                     exec refuses, nothing written.
            silent control: companions deleted, one shared
                     `contentparams: [{1} = PictureUrl]` left -- check,
                     exec and `mx check` (0 errors) all clean, and BOTH
                     stored ClientTemplates bind PictureUrl, so the alt
                     text renders the URL. Valid, buildable, wrong.

  fixed     check -> Check passed!  exec -> Created page
            mx check -> 0 errors
            stored BSON -> imageUrl={1}[PictureUrl],
                           alternativeText={1}[Name]
            describe -> exec -> "Unchanged page" (round trip stable)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXYNJwiutu5AjxLmG7Fgsu
… spell

The entity body authors exactly two rule types — Required from `not null` and
Unique from `unique`. RegEx and Range are authored by a separate
`create validation rule` statement and have no spelling in the entity body at
all, but the merge listed ValidationRules among the fields the statement is
authoritative about, so every rewrite overwrote the whole list and deleted them.

The visible symptom was churn rather than loss, because the scripts that hit it
re-create the rule in the next statement: an idempotent script reported
`Modified entity: X` + `Created RegEx validation rule on X.Attr` on every run
forever. Measured on the v1 fixture, the unit came back the same size (1,368
bytes) with 62 bytes differing, all of them element $IDs under
ValidationRules/1. Splitting the two statements says which one moves — the
entity rewrite ALONE takes the unit from 1,368 to 947 bytes.

The loss is the real defect, and it is silent. Measured on a real 11.14.0
project, `create or modify entity` adding one attribute deletes the entity's
RegEx rule and `mx check` reports "The app contains: 0 errors" — a re-created,
or absent, rule is a valid model either way.

Carrying an unreadable rule (MaxLength, EqualsTo) matters for a second reason:
the model has no payload type for either, so it is UpdateEntity's
guard-don't-drop refusal that must see it. Dropping it here skipped the guard
entirely and turned "mxcli will not rewrite this entity" into a silent loss.

A rule is dropped only on positive evidence that this rewrite removed its
attribute — the stored entity owned one of that name and the rebuilt one does
not. "Not among the declared attributes" is the wrong predicate and wrong in the
direction that loses data, since an entity's rules can name inherited members
that never appear in its own Attributes list (the trap
pruneMemberAccessesForDroppedAttributes already paid for).

ValidationRules is now in neither category of the field drift guard, so
entityFieldsMergedWithStored is the third, and listing a field there is the
deliberate act of taking it out of that guard's reach.

`create validation rule` also reports through ReportMutation now, so a statement
whose write was elided says Unchanged like every other one.

Refs: #556

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWZjWZQhk3cNQCcy1Z2xzH
Several `create or modify` handlers are implemented as delete + create under the
preserved unit ID rather than as an update. canon.Reconcile lives in updateUnit,
so those writes never reached it and the rebuild's fresh element $IDs went
straight to disk. Measured on a real 11.14.0 project, two consecutive identical
runs of one `create or modify rest client` statement: the 1,128-byte unit came
back the same size with 143 bytes differing, in 9 runs, every one an element
$ID — /BaseUrl/$ID, /Operations/1/$ID, its Method, Path, Headers, Parameters and
ResponseHandling.

Feeding those two stored versions straight to canon.TransplantIDs makes them
byte-identical, so the policy was never wrong here. It was never called. This is
CLAUDE.md's "a new write path means wiring it to canon.Reconcile" in the shape
that hides: the new path is not a new function, it is insertUnit reached by way
of deleteUnit.

deleteUnit now remembers what it removed (bounded at 1024 entries, past which the
carry degrades to today's behaviour) and insertUnit reconciles a re-insert
against it. The carry cannot elide — the row and the file are already gone — so a
recreate that put back exactly what it removed also restores
_Transaction.LastTransactionID, which both the delete and the insert bumped.
That second half is load-bearing: with only the byte carry, every row of the
SQLite db was logically identical and the .mpr still showed as modified, because
that one UUID had moved.

`create or modify rest client` is also fixed at its own layer, which is better
than relying on the safety net: when the statement leaves the service where it
is, it now calls UpdateConsumedRestService and the write is elided outright.
Delete+create survives only for a folder move, which lives in the unit's row
rather than in its contents. Deferring the delete to the write point is also
strictly safer than doing it while scanning — building the service from the AST
can fail, and the old code had already deleted the stored one by then.

Measured end to end on 11.14.0, three consecutive re-runs of an identical
statement: `Modified rest client` and 2 changed files every time before,
`Unchanged rest client` and a clean tree after. A real edit (changing an
operation's path) still lands, still reports Modified, and still builds at 0
errors.

Refs: #556

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWZjWZQhk3cNQCcy1Z2xzH
One idempotent script covering both named instances of #556. Running it
twice must leave the project byte-identical.

The control is a binary built with both fixes stashed out, run on the same
project: 3 changed files on every re-run, and `mx check` reporting "The app
contains: 0 errors" each time — which is why the version-control diff was the
only signal this class ever produced.

Refs: #556

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWZjWZQhk3cNQCcy1Z2xzH
…g it

`CREATE OR MODIFY VIEW ENTITY` that changed ONLY the OQL printed

    Unchanged view entity: Mod.Totals

while `describe entity` showed the new query stored. Changing the attribute
list as well reported Modified correctly, which is why it hid.

A view entity's OQL lives in a separate DomainModels$ViewEntitySourceDocument
unit, and the executor DELETED that unit and INSERTED a fresh one on every
write. Two costs, measured:

  - `ReportMutation` downgrades the verb when writes were offered and none
    landed, but the counters live at the update choke points and an InsertUnit
    is not one. The domain-model unit was offered and correctly elided, the OQL
    write was invisible, and the report believed the half it could see.
  - the unit was replaced under a fresh GUID on EVERY run, including a
    byte-identical one, so an MDL-generated project could never come back clean
    in version control. #556 counted these units.

WriteViewEntitySourceDocument keeps the stored unit's id and goes through
UpdateRawUnit, which reconciles against what is stored (ADR-0008): an identical
query is elided, a changed one lands and is counted, duplicates are still
cleared. Create and update now share one encoder so they cannot drift into
writing different documents for the same query.

Counting InsertUnit instead would have swapped a false "Unchanged" for a false
"Modified" — measured: a byte-identical re-run re-minted the unit id, so every
view-entity statement would have reported Modified forever. That measurement is
why the fix is the reconcile wiring rather than the counter.

Verbs on the bug-test, in order, all four correct:

    Created / Modified / Modified (OQL only) / Unchanged (identical re-run)

Unit id now stable across an identical AND a changed re-run; mxbuild 11.14.0
reports 0 errors on the result. Control: with the old delete-then-create
restored inside the new method, the test fails with the reported symptom —
"a changed OQL landed no counted write (Written stayed 0)".

Residual, deliberately not chased here: any content reaching storage through
InsertUnit is still invisible to the elision check, so a statement whose only
landing write is a NEW unit can be mis-reported. MoveUnit carries a comment
explaining it was counted for exactly this reason; insert and delete were
missed. Noted in #583 rather than changed unmeasured.

Closes #583

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2aYb3zDscezm874CDTH6R
TreeNode and Timeline — the widgets the issue was filed against — do have a
generic describe path: every pluggable widget outside isKnownCustomWidgetType's
list of nine goes through extractExplicitProperties. That path read AttributeRef
and PrimitiveValue only, so EVERY text-template property of those widgets was
absent from `describe page` whether it was bound or literal, and describe -> exec
dropped the caption outright.

Measured on a blank 11.14.0 project, before this commit:

  treenode tnProduct (
    advancedMode: false, headerType: 'text', openNodeOn: 'headerClick',
    hasChildren: true, startExpanded: false, animate: true,
    showIcon: 'left', animateIcon: true
  )

Ten primitives and no caption — which is why it looked complete. After:

  treenode tnProduct (
    DataSource: database from Bug575.Product, advancedMode: false,
    headerType: 'text', openNodeOn: 'headerClick',
    headerCaption: '{1}', headerCaptionParams: [{1} = Name], ...
  )

and the same for a Timeline's title / description. Both pages round-trip:
describe -> exec reports "Unchanged page", mx check 0 errors.

Also corrects the bug test, which used the built-in Image on the stated
grounds that the reported widgets needed a Marketplace download. They do not:
TreeNode.mpk and Timeline.mpk ship in a blank app's widgets/ and their defs are
generated by `mxcli init`. The test now exercises all three — the Image earns
its place because its def.json names the mappings by SOURCE (`ImageUrl`) where
the TreeNode's names them by schema key, and the two take different branches of
resolveMapping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXYNJwiutu5AjxLmG7Fgsu
fix(executor): resolve a retrieve's sort hop across ancestors and modules
MDL031's pass-through length rule refused EVERY declaration of a column taken
from a System.* string attribute — including the one that builds — and
suggested the one that does not.

Measured on mxbuild 11.14.0. System.User.Name is String(100), not the 200 the
field report and my own issue text both assumed:

    declared      mxcli check (before)   mx check
    String(200)   refused                CE6770
    String(100)   refused                0 errors    <- the correct one
    String        passed                 CE6770

The inferred length was 0 because the System metadata carries no lengths (0 of
115 attributes, #584), and the rule treated 0 as a length to match rather than
as "not known". formatDataTypeForMDL then substituted a hardcoded String(200)
for the unknown length, so the message refused String(200) and prescribed
String(200) in one sentence — and on this attribute that value is precisely
what mxbuild rejects.

passthroughStringLengthMismatch now declines a source length of 0. The rule
still fires wherever mxcli has the length, with its original message and exact
advice, verified end to end:

    attribute 'MeterName': declared as String(200) but pass-through column
    'm.MeterName' inherits length 100 from source attribute Mod.Meter.MeterName
    — Mendix requires an exact length match (CE6770 …).
    Fix: change to 'MeterName: String(100)'

The hardcoded String(200) in formatDataTypeForMDL is untouched: a DERIVED
string column is String(200) by rule whatever its source, so the two
type-mismatch suggestion sites want it. Only the pass-through branch, where the
number belongs to the source, is affected.

The trade, stated rather than hidden: mxcli no longer catches a wrong length
over a System.* attribute. It did not really catch one before — it refused
everything, which is why the reporter rewrote their column as
cast(u.Name as string) — but the gap is now honest, and #584 closes it by
supplying the lengths.

Control: with the unknown case forced back through the old wording, the test
fails with the reported message verbatim, including "inherits length 0" and the
invented suggestion. The known-length case is asserted alongside it, so a build
that simply deleted the rule fails too.

Closes #585

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2aYb3zDscezm874CDTH6R
fix(check): report the members a script removes from the project (MDL087)
fix(view entity): write the OQL document in place instead of replacing it
fix: re-exec is identity-stable for validation rules and REST clients
fix: session-auth OData needs X-Csrf-Token, and the vega pack said it did not
fix(view entity): do not judge a pass-through length mxcli does not know
`meta.SystemAttrDef` declared a `Length` and not one of the 115 String
attributes populated it, so `systemAttrType` built every System string as
`StringAttributeType{Length: 0}` — unlimited — and every length comparison
against a System attribute was made against 0.

The cost was not cosmetic. A view entity selecting `u.Name` from System.User
could not be declared in any way that both passed check and built:

  declared      mxcli check (before)  mxcli check (after)   mx check 11.14.0
  String(100)   refused               passes                0 errors
  String(200)   passed (after #585)   refused, "use 100"    CE6770
  String        passed                refused, "use 100"    CE6770

The reporter worked round it with `cast(u.Name as string)`, where the derived
column rule fixes the length at 200 whatever the source.

The lengths are measured, from the System module's domain model inside the
`deployment/model/model.mdp` mxbuild writes — the model the runtime builds the
System tables from, so the same number CE6770 is decided by. That is one
`--target=deploy` for all 115, against ~40s per attribute for the view-entity
probe; the Model SDK does not carry them at all (its gen/ describes metamodel
types) and the modeler's copy is inside Mendix.Modeler.Core.dll.

Whether the numbers are version-specific was measured rather than assumed:
10.24.4.77222 and 11.14.0 agree on all 216 attributes they share, types and
lengths alike, so one table serves every supported version.

- testdata/system_string_lengths.txt holds the measurement, with the command
  that regenerates it.
- TestSystemStringLengths holds SystemEntities to it in BOTH directions, so a
  String attribute added without a measurement fails rather than defaulting to
  unlimited. That is what makes a stored 0 Mendix's "unlimited" rather than
  "nobody looked" — 46 of the 115 genuinely are unlimited.
- TestSystemStringLengthsUpdate is the measuring half: point it at a built
  model.mdp with -mdp, add -update to rewrite the golden.

Control: reverting the populated lengths fails both new tests with the reported
symptom ("System.User.Name: read as String(0), mxbuild builds it as
String(100)"). End to end on mxbuild 11.14.0, the String(100) view entity is 0
errors and the String(200) one is CE6770, matching mxcli's new verdicts.

The 29 System members 11.14.0 has that this table does not are left alone: a
member absent from the target version is CE1613, which is a version-gating
question a length never is.

Closes #584

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2aYb3zDscezm874CDTH6R
Bind a pluggable widget's textTemplate to an attribute (#575)
Measure the System module's String lengths (#584)
@ako
ako merged commit b6756aa into mendixlabs:main Sep 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants