Skip to content

Sync ako/mxcli: silent data-loss fixes, IN QUEUE, and capability docs corrected against measurement - #921

Merged
ako merged 59 commits into
mendixlabs:mainfrom
ako:main
Aug 18, 2026
Merged

Sync ako/mxcli: silent data-loss fixes, IN QUEUE, and capability docs corrected against measurement#921
ako merged 59 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Syncs ako/mxcli:main into mendixlabs/mxcli:main.

59 commits (35 non-merge, 24 merges), 192 files changed, +10905 −771.
Range is a clean fast-forward — git merge-base --is-ancestor holds, no rebase
or squash divergence.

Most of this came out of triaging two real applications' findings ledgers
(ako/mxcli-owid, ako/mxcli-banking) against measurement on mxbuild 11.13,
so several entries below are corrections to things mxcli reported as working.

Silent data loss — a write that succeeded and dropped the input

Each of these passed mxcli check, reported success on exec, and produced a
project mx check called clean. That is the whole problem: nothing downstream
signalled the loss.

  • OnChange: was written only on textbox. The other five input widgets
    never read the property; the legacy writer hardcoded serializeClientAction(nil)
    for them; and for pluggable widgets the action-slot matcher missed Combobox's
    onChangeEvent spelling. Three independently sufficient layers. In the browser
    the control produced zero /xas/ requests — no server round-trip at all.
  • DESCRIBE could not read a pluggable widget's action back, so a
    describe → edit → exec cycle deleted it (measured: 1 Forms$MicroflowAction
    before, 0 after).
  • A database connection's SQL was replaced by a parameter's default. With the
    query written as $…$, STRING_LITERAL(0) is not the query — it is the first
    parameter's DEFAULT '…'. Also a lossy read of query parameters on the default
    engine, where the renderer was dead code.
  • The queued-call rewrite guard was a no-op on --engine legacy. The legacy
    reader returns bson.A, a named slice type, so case []any silently did not
    match and the walk never descended into the document. The guard existed and
    protected nothing on that engine.
  • Excluded documents came back on rewrite, and rewrites targeted a stale twin.
  • A replaced microflow lost its StartEvent position (diff churn on every
    re-run of an unchanged script).
  • A required object-list TextTemplate serialized without its shipped default
    (ALTER PAGE cannot address widgets inside a pluggable widget, and DataGrid2 column edits are silently destructive #891).

New authoring surface

  • IN QUEUE on CALL MICROFLOW / CALL JAVA ACTION. Binding a call to a
    task queue was previously unauthorable, which is why rewriting a microflow that
    had one had to be refused outright (guard-don't-drop, ADR-0005). The refusal is
    now narrow: restating every stored queue is allowed; dropping one, or a retry
    policy MDL cannot express, still refuses.
  • RENAME ATTRIBUTE updates cross-references, including rewriting XPath
    constraints (SET DEFAULT NULL rewrites an attribute's type (Integer becomes Enumeration), and RENAME ATTRIBUTE does not update microflow/page references #910).
  • The remaining model settings are exposed, and no longer leak across Mendix
    versions; optimistic locking is settable from MDL.
  • MODIFY ATTRIBUTE rejects an unknown type; DROP DEFAULT added.

Commands that exited 0 without doing anything

  • show page X exited silently — a grammar alternative with no visitor
    branch. Now aliased to DESCRIBE PAGE, with a coverage test that walks the
    grammar's show alternatives so a new one cannot be added without a branch.
  • MDL003 demanded a RETURN the builder synthesizes, failing correct
    scripts.
  • An action slot is authorable by its Source, not its storage key — mapping
    the storage key made onChangeEvent: accepted MDL that nothing ever read.
  • exec now refuses a script whose own checks report an error instead of
    running it.
  • An unknown model setting is named, rather than blamed on the Mendix version.

Lint

Test runner and warm loop

  • @verify implemented (it had been parsed and ignored), and the OQL path it
    runs on fixed.
  • mxcli test leaves the project byte-identical after a run.
  • run --local --watch waits for a write to finish before building.

Docs corrected against measurement

The failure being fixed here is a document that keeps asserting a gap after the
gap was filled — a reader greps it, believes the feature is unavailable, and
builds a workaround around a blocker that no longer exists. The External Database
Connector had been listed unsupported long after CREATE DATABASE CONNECTION
shipped.

  • The feature matrix is now audited against Studio Pro's own document-type
    list
    , not against mxcli's — auditing against yourself cannot find a missing
    document type.
  • A drift guard test pairs each capability with its registered mxcli syntax
    topic, so the matrix cannot re-claim a shipped feature is missing.
  • MISSING_CAPABILITIES.md is marked as a dated survey (Mendix 11.6.3) ahead
    of its first table, with every row carrying an explicit status.
  • CASE / enum-split support settled, fixing seven contradicting surfaces; where
    an enum may be a string literal is pinned per context, as measured.
  • One reported finding was refuted by measuring it on 11.13 rather than fixed.
  • OQL ORDER BY … DESC on a nullable column puts nulls first (documented).

Cleanup

  • The dead notebook grammar removed (rules, tokens, and the generated LSP
    completions that still advertised NOTEBOOK).
  • LSP completion keywords regenerated from the current lexer

claude and others added 30 commits August 17, 2026 11:29
…EFAULT

mendixlabs#910. `ALTER ENTITY … MODIFY ATTRIBUTE UserID SET DEFAULT NULL`
silently rewrote an Integer attribute to `Enumeration(set)`.

MODIFY ATTRIBUTE always parses a type, and the grammar's last dataType
alternative is a bare qualifiedName (an entity reference), so `SET` was read
as the type and `DEFAULT NULL` as a constraint. The executor then wrote an
EnumerationAttributeType with no enumeration behind it.

Two things make this worse than reported. It is not specific to SET DEFAULT
NULL — an ordinary typo (`MODIFY ATTRIBUTE A Integr`) corrupts identically.
And the result is not merely wrong, it is unloadable: mx check dies before
running any validation with

  System.ArgumentNullException: Value cannot be null. (Parameter 'value')
    at EnumerationAttributeType.set_EnumerationId

CREATE ENTITY has rejected exactly this since mendixlabs#552, but the guard was inline
in the create handler and never reached MODIFY. It is extracted to
validateAttributeTypeRef and applied on the modify path before the attribute
is touched.

Refusing alone would leave the reporter's actual goal unreachable, since
restating a type is the only way to keep a default and there was no way to
clear one. So DROP DEFAULT ON ATTRIBUTE is added, and the refusal names it. A
calculated attribute is refused rather than silently converted to a plain
stored one.

mxcli's own `syntax` topic documented `MODIFY ATTRIBUTE AttrName SET DEFAULT
val` — the corrupting form — which is the likeliest way the reporter arrived
at it. Verified against a pre-fix binary: that documented statement produced
`Enumeration(set) default set.5`. The topic now shows the type as mandatory,
names the trap, and lists DROP DEFAULT.

Verified end to end on Mendix 11.13.0: before, mx check could not load the
project; after the same flow it reports 0 errors. Each test was checked to
fail against a stubbed guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Verifies the 8-slice feedback report from the mxcli-banking app against
this checkout. 13 of 14 mxcli-side claims reproduce; roots pinned for 12.

Notable:
- DROP ATTRIBUTE's validation-rule cleanup is dead code: the executor
  matches on element ID while parseValidationRule stores a qualified
  name in AttributeID. Same confusion affects MemberAccess.
- Microflow ApplyEntityAccess is hardcoded false in both engines with
  no MDL syntax to set it, which is the reported datasource leak.
- CONV010's ALLOWED_ACTIONS uses BSON storage names the linter never
  sees; QUAL004 counts only call/schedule edges.
- Two new defects not in the report: dropping the last attribute of an
  entity is a silent no-op, and a synced skill documents a
  NON_PERSISTENT spelling that does not parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
…abs#910)

`alter entity Mod.Ent rename attribute Old to New;` renamed the attribute
inside the domain model and stopped there. Everywhere else Mendix stores an
attribute reference it stores the fully qualified name as a string, so
microflow create/change members, page attribute widgets, and the entity's own
validation and access rules all kept pointing at the old name. mxbuild reports
each one as CE1613 "The selected attribute 'Mod.Ent.Old' no longer exists.";
Studio Pro still opens the project, so nothing looks wrong until a build.

`mxcli rename` already had the project-wide reference scanner for entities,
enumerations, associations, modules and documents — attributes were the one
kind never wired to it. The handler now calls RenameReferences and reports how
many documents it touched.

Order is load-bearing: the scan runs AFTER UpdateEntity, not before. It is a
raw-BSON pass over every unit including the domain model, while UpdateEntity
re-serializes the whole domain model from the parsed entity — scanning first
would hand the model write the last word and undo the scan's edits inside the
domain model, which is where a validation rule's Attribute string and a
MemberAccess.Attribute live. execRenameEntity gets away with the opposite order
only because entity refs within the domain model are binary pointers.

Also adds the missing collision guard: renaming onto a name the entity already
uses produced two attributes no reference could tell apart.

Scope, stated rather than hidden: uses inside expressions ($obj/Attr) and XPath
constraints ([Attr = ...]) are free text where a bare name is only resolvable
from the type of what precedes it, so they are not rewritten. mxbuild reports
those as CE0117 / CE0161 and the command now says so instead of reading as
complete.

Controls on Mendix 11.13.0 with the new bug-test script: a build from origin/main
leaves 3 errors after the rename (2 microflow members + the validation rule),
this build leaves 0. Each new unit test was shown to fail with the reported
symptom against the stubbed-out fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
…shipped default (mendixlabs#891)

Authoring an Accordion raised CE0463 "the definition of this widget has
changed" -- on a widget created seconds earlier against the very package
it was built from.

An object-list item's REQUIRED TextTemplate that the author leaves unset
was serialized as null. The empty-ClientTemplate convention lived in
emptyClientTemplateRules, a hardcoded per-widget table covering only
DataGrid columns, so every other object-list widget fell through it. In a
stock blank app the Accordion's group (required headerText) failed, and
thirteen shipped widgets declare object lists with texttemplate items.

Required-ness is genuinely absent from the widget XML here -- and the
widget schema defaults `required` to TRUE -- so headerText is mandatory
though nothing says so. It now comes from the widget's own PropertyTypes,
needing no per-widget table and unable to go stale against a package
upgrade.

Both weaker forms were measured and rejected. null is CE0463; an EMPTY
Forms$ClientTemplate is CE4899 "Property 'Groups/1/Text' is required" --
so the intuitive "emit an empty template" fix only moves the error. Only
the widget's shipped <translations> satisfy both, which is what
mx update-widgets writes: mxcli and the reference now emit the same
'Header'/'Koptekst'.

Scoped to REQUIRED properties. An optional unset TextTemplate keeps its
null, matching Studio Pro; filling every one is the documented way to take
CE0463 from 33 to 127.

Measured against a baseline fixture (accordion + pop-up menu + a plain
DataGrid2): 3 errors -> 2, no new errors of any code, DataGrid2 untouched.
Control run with the call site removed fails the new test.

PropertyTypeIDEntry exists in three packages, so the field is carried
through convertPropTypeIDs; without that it silently never arrives.

The pop-up menu's remaining CE0463 is a DIFFERENT defect -- its `caption`
is required="false" in the XML, so this rule correctly does not fire on
it.

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

The half of mendixlabs#910 the reference scanner cannot reach — attribute names inside
microflow expressions ($Order/Status) and XPath constraints ([Status = 'Open'])
— needs exactly the resolution machinery PROPOSAL_expression_type_checking
already builds for its check and refs consumers. Adds it as a fourth consumer
alongside those, and links the dependency from the rename proposal's scope
exclusions (which did not mention the gap, and listed attribute rename as
plainly "Works").

Three things worth having written down rather than rediscovered:

- The XPath half needs no type system and is shippable before build-order
  step 1. A constraint's target entity is known structurally, the parse and
  re-render round trip already ships (ParseXPathConstraint / expressionToXPath
  / SplitXPathPredicateGroups), and enrichXPathConstraintForDescribe is a
  working precedent for resolving bare attribute names against a known entity.
  Only multi-hop association paths need the resolver.

- The expression half is genuinely step 1, and is the one place the dormant
  exprcheck parser is in the hot path: stored expressions come back from BSON
  as raw strings, not as the mdl/ast nodes the visitor produces.

- The proposal's failure mode inverts for a mutating consumer. Unresolved →
  KindUnknown → "catch less, never false-positive" is right for an advisory
  check; a rename must instead refuse to rewrite and report, because silently
  rewriting an occurrence whose type could not be established corrupts a model
  that was valid. That is a constraint on the resolver interface, not on its
  callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
ALTER SETTINGS MODEL refused every spelling of Mendix's optimistic
locking setting with a bare 'unknown model setting', which put a
security-relevant runtime setting on the needs-Studio-Pro list. Reported
by the mxcli-banking app as the unclosable half of a read-then-write
balance race in a transfer microflow.

    alter settings model EnableDataStorageOptimisticLocking = true;

Every layer but one was already in place: model.ModelSettings carried the
field and both engines read and wrote it, so the property round-tripped
faithfully on every write while being unreachable from MDL. Only the
executor's assignment case was missing.

The stored key is verified against Studio-Pro-created projects on both
9.24 and 11.13.0. Unlike JavaVersion -> JavaMajorVersion (renamed between
11.6 and 11.12) this one did not move, so a single spelling is correct
for every supported version.

The unknown-key error now lists the accepted model keys, guarded by a
drift test. The report tried three wrong names in a row and was never
pointed at the right one.

Verified on an 11.13.0 project: correct BSON key written with
JavaMajorVersion preserved beside it, mx check 0 errors, re-running is
byte-identical (MXCLI_ALWAYS_WRITE=1 as the control that does churn), and
a non-boolean value is refused by mxcli check (MDL-SET02) and the write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
…endixlabs#910)

XPath names an attribute as a bare step — [Status = 'Open'] — so the
qualified-name scan that fixes "Mod.Entity.Attr" references cannot see it, and a
scan for the bare name would corrupt string literals, function names and other
entities' identically-named attributes. Renaming therefore left every constraint
dangling, which mxbuild reports as CE0161.

No type inference is needed for this half. A constraint's target entity is known
structurally — a retrieve names its Entity, a widget data source its
EntityRef.QualifiedName, an access rule the entity it sits inside — and every
further hop is an association or entity named in the path, resolved against a
flat index built from the domain models.

Three design points, each of which cost a wrong first attempt:

- The edit is textual, gated by the parse, not a re-render of the parsed tree.
  Re-rendering would churn spacing in constraints the rename has no business
  touching, and any parser/renderer disagreement would corrupt a working one.
  Only a single identifier token changes.

- The load-bearing safety check is a count invariant. ParseXPathConstraint runs
  with ANTLR's error listeners removed, so it recovers and can hand back a tree
  that silently omits part of its input — [LastName = 'L' FirstName] parses and
  the trailing step is simply absent. Requiring the lexical occurrence count to
  equal the walked occurrence count is what makes leaning on that parse safe.

- A mutating consumer needs three answers, not two: this entity's attribute
  (rewrite), a different entity's (leave alone, say nothing), and cannot tell
  (leave alone, report). Collapsing the last two made the pass silently skip
  constraints it should have questioned.

Also fixes a second defect the same rename exposed. UpdateEntity re-derives an
access rule's members from the attributes it can match, so renaming in the model
orphaned the old member and the reference scan then renamed the orphan into a
duplicate — three members for a two-attribute entity, reported as CE0066
"Entity access is out of date". The entity's own by-name references are now
fixed in the model before the write.

Trap worth recording: a domain model entity node is stored under
DomainModels$EntityImpl, not DomainModels$Entity (that is the metamodel's name
for a reference target). Matching the latter matched nothing and made every
access rule look like a cautious refusal rather than a bug.

Controls on Mendix 11.13.0 with the new bug-test script: the same script without
the rename is 0 errors; with the rename it was 3x CE0161 and then 1x CE0066;
it is now 0. On the earlier repro the progression is 5 errors (pre-fix) to 3
(qualified-name half) to 1 — and that one is the microflow expression, which is
reported as out of scope and needs the resolver in
PROPOSAL_expression_type_checking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
… them across versions

Adds FirstDayOfWeek, DecimalScale, DefaultTimeZoneCode,
UseDatabaseForeignKeyConstraints, UseOQLVersion2 and SslCertificateAlgorithm
to ALTER SETTINGS MODEL. The two enum-typed ones are canonicalised against
generated/metamodel and a non-member is refused (MDL-SET03) rather than
written through: an enum value the metamodel cannot resolve is what makes
Studio Pro throw 'Sequence contains no matching element'.

Fixes a defect found while checking whether that was safe. A blank Mendix
9.24 project stores 12 model settings; a blank 11.13 stores 17. Both
engines' overlays wrote every known property unconditionally, so on a 9.24
project 'alter settings model BcryptCost = 11' also introduced
DecimalScale = 0 and UseDatabaseForeignKeyConstraints = false -- properties
that version does not store, at values that are wrong anyway (11.13
defaults them to 8 and true). One unrelated statement silently changed two
other settings, and a property the type may not define is what Studio Pro
refuses to open while mxbuild loads it happily.

The overlay is now presence-gated and shared by both engines rather than
duplicated (as Configurations already was). The executor refuses an ALTER
naming a property the stored document does not carry, so the gate never
becomes a silent no-op, and DESCRIBE emits only stored properties so its
output replays on both versions.

UseSystemContextForBackgroundTasks is read and preserved but deliberately
not writable: mx check on 11.13 rejects a project holding true with CE9436
'not supported anymore', so its only legal value is its default.

Verified on real 9.24 and 11.13 projects: the 9.24 key set is unchanged
through a write, 11.13 takes all six with mx check at 0 errors, both
describe outputs replay, re-running is byte-identical with
MXCLI_ALWAYS_WRITE=1 as the control, and the regression test reproduces the
leak when the guard is stubbed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
mxbuild 11.13.0 is cached now, so finding #14 was tested rather than
reasoned about. It does not reproduce: SHOW ENTITIES lists the view as
Type: View, DESCRIBE round-trips it and its OQL, GRANT succeeds, a
microflow RETURNS LIST OF it, '--' comments in the OQL body parse, and
CREATE OR MODIFY prunes members the OQL no longer produces. The whole
chain passes mx check with 0 errors.

No fix landed in the window that would explain it. The reporter's own
notes describe a view that had been dropped and never recreated, which
would make 'entity not found' from GRANT and RETURNS, plus the absence
from SHOW ENTITIES, correct answers rather than three bugs.

This matters because the finding is what killed their dashboard design.

Two things in the area are real, and recorded: a view entity needs
UseOQLVersion2 = true or the build fails CE6779 (a setting that was not
reachable from MDL until the previous commit), and cast() parses without
a length but not with one, so a pass-through String(unlimited) column
still cannot be narrowed.

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

write-microflows.md documented `case … when … end case;` as supported
(with CE0079/MDL056/MDL008 detail), then declared "CASE/SWITCH not
supported — use nested IF" 1000 lines later. Five other surfaces sided
with the wrong half, and the generated project CLAUDE.md gave a third
shape that parses in neither.

Settled against the binary rather than by argument, one variant per
claim:

  case $Status when Open, Pending then … when (empty) then … end case;
                                                          -> passes
  case $Order/Status when Open then … else …                -> MDL008
  case $Order/Status as s …            -> mismatched input 'as'
  when Module.Enum.Value …             -> mismatched input '.'
  when 'Active' …                      -> extraneous input ''Active''

So CASE is supported, in microflows and nanoflows alike, for enum splits
only: bare enum values, a branch per value including `(empty)`, no
`else`, no alias. Notably the old "WRONG" example failed for an
unrelated reason (string-literal values), which is what made the false
claim look confirmed.

Corrected: write-microflows.md (the unsupported section now pins the
three spellings that really fail), check-syntax.md, MDL_QUICK_REFERENCE
(which also contradicted itself), docs-site control-flow (given a proper
CASE section — it had no CASE docs at all, only two denials),
appendixes/{quick-reference,common-mistakes}, and init_claudemd.go.

Pinned both directions in mdl-examples/bug-tests/ so `make check-mdl`
catches drift back: a positive fixture for the documented forms and a
.fail.mdl for the quoted/qualified/alias spellings. Symptom row appended
to fix-issue.md, including why the existing skill-MDL gate could not
have caught this (it skips microflow bodies).
The skills said "NEVER use a string literal for an enumeration"; docs-site
used `Status = 'Draft'` in a dozen CREATE/CHANGE examples and called it
"plain strings when the context is unambiguous". Both are half right, and
`mxcli check` passes the good and the bad form alike, so the build is
where a reader finds out which half applied to them.

Measured on a real project rather than argued — mxcli new 11.13.0, one
microflow per construct, `mxcli docker check` read per construct:

  if $O/Status = 'Draft' then …       CE0117 "Error(s) in expression."
  log warning 'x: ' + $O/Status;      CE0117 "Error(s) in expression."
  change $O (Status = 'Submitted');   clean
  create M.E (Status = 'Draft');      clean
  DEFAULT 'Draft' on the attribute    clean
  retrieve … where [Status='Draft']   clean
  getCaption(…) / toString(…) concat  clean

The string form is accepted wherever the slot is already enum-typed, and
rejected in an expression, where it is a String compared to an
Enumeration. So only comparisons and concatenations were fixed:
control-flow.md (its nested-IF example had both defects — three string
comparisons and an enum concatenated into a LOG message),
create-microflow.md, and expressions.md, whose "unambiguous context"
sentence now names the four accepted slots and the two that are CE0117.
A sweep-and-replace over every `Status = '…'` would have "corrected" ten
sites that build fine, including the XPath one where the string is the
only correct form.

The skill gains the same table, plus getCaption()/toString() for putting
an enum into a string, and a note that check cannot catch either failing
form (the type checker is still a proposal).

No .fail.mdl is possible here, so the verdicts live in
mdl-examples/bug-tests/enum-string-literal-contexts.mdl, pinning the
accepted forms so a later fix does not widen into them. Control: adding
it to the probe project left the error count unchanged at the two
deliberate failures.
Serialize a required object-list TextTemplate with its shipped default, fixing CE0463 on authored Accordions (mendixlabs#891)
docs: triage mxcli-banking FINDINGS.md against current main
docs: settle contradictory enum documentation (CASE splits, string literals)
mxcli exec ran no semantic checks. Parse errors stopped it, but the ~24
Validate* checks lived inline in cmd_check.go with exactly one caller, so
a script mxcli check rejected was applied by exec anyway. The
mxcli-banking report hit this with a page whose invalid widget property
was silently dropped -- a picker that renders and does nothing. 'Run
check before exec' was a convention nothing enforced, on a tool built for
unattended agent use.

exec now runs the same checks first and refuses when any reports an
error: nothing is written, because exec applies statements one at a time
and cannot roll back, so a known-bad script would leave the model partly
updated. Warnings print and do not block. --no-check opts out.

The checks moved to executor.ValidateProgram rather than being copied,
because a second copy would drift -- the same failure that produced the
CONV010 and QUAL004 false positives and the duplicated settings overlays.
mxcli check now calls it too, and its output is byte-identical across all
20 doctype-test scripts.

A check wired into nothing is invisible: it reports no violations and
reads as a clean project. TestValidateProgram_WiresEveryWholeProgramValidator
therefore asserts structurally that every exported
Validate*(prog *ast.Program, ...) is called from ValidateProgram, and
fails when one is dropped.

Verified on a real 11.13 project: a combobox with an unknown property is
refused with nothing written, the same script under --no-check writes the
page with the property silently dropped, and a warning-only script still
applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
An `OnChange:` authored on a checkbox, radiobuttons, combobox, dropdown,
textarea or datepicker parsed cleanly, passed `mxcli check`, and executed
with a success report — while the property was never written. In the
browser the control changed state and issued zero /xas/ requests.

Three independent drops, any one of which was sufficient:

1. The page builder read `w.GetOnChange()` in `buildTextBoxV3` only, so
   the property died between the AST and the model for the other five.
   All six now route through one `applyOnChangeV3` helper, so a widget
   added later cannot forget it.

2. The legacy writer hardcoded `serializeClientAction(nil)` for TextArea,
   DatePicker, CheckBox, RadioButtons and DropDown — a populated action
   would not have reached BSON under `--engine legacy` either.

3. For pluggable widgets `actionSourceForKey` matched only the bare keys
   `onClick`/`onChange`, but Mendix's Combobox names its slot
   `onChangeEvent`, so no action mapping was emitted at all. One
   Event/Action suffix is now stripped before matching — narrowly, so the
   Combobox's `onChangeFilterInputEvent` and `onChangeDatabaseEvent`
   (distinct properties with no MDL surface) stay unmapped rather than
   having one `OnChange:` write three actions.

The shipped combobox.def.json overrides any .mpk-derived def, so the
generator fix alone would never have reached a real combobox; the mapping
is added there too, in both modes (modes are exclusive). Generator version
bumped 14 → 15 so existing projects regenerate.

DESCRIBE is wired at the same time for textarea / datepicker / checkbox /
radiobuttons: DESCRIBE is how this class of drop gets found, and it could
only see OnChange on textbox. (`dropdown` has no DESCRIBE case at all —
a separate gap, left alone.)

Control: reverting the combobox def alone fails
TestCombobox_OnChangeMappedInBothModes with the reported symptom.

Reported as FINDINGS #14 in ako/mxcli-owid.

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

The presence gate added in #163 runs before the switch that rejects an
unrecognised key, so a typo was answered with

  this project does not store the model setting NotARealSetting
    ... hint: set it in Studio Pro once, or upgrade the project

which sends the reader after a version problem that does not exist. That
is a regression against the message the key list was added for: a bare
'unknown model setting' had already sent the mxcli-banking app through
three wrong guesses at the optimistic-locking property.

An unknown key and a real key this version does not store are different
problems with different fixes, and the presence check cannot tell them
apart -- so check membership first. The version-specific message stays
reachable for a real key, which the second test pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Mendix can run both a Call microflow and a Call Java action activity on a
task queue; it is a property of the call activity, not a separate
construct. MDL could express it on neither, so a queue could be created
and never bound to anything. The gen setters existed and nothing called
them.

    CALL MICROFLOW   Ops.ACT_Process(Order = $Order) IN QUEUE Ops.Work;
    CALL JAVA ACTION Ops.RefreshData(Url = $Url)     IN QUEUE Ops.Work;

The clause takes the same position on both — after the argument list,
before any ON ERROR — and DESCRIBE renders it back, so the binding
round-trips.

What is written is a Queues$QueueSettings child, NOT the call's sibling
`Queue` string. gen carries a `Queue` ByNameRef on both call types;
generated/metamodel (the arbiter) has neither, and a call carrying only
`Queue` is inert. Writing it would have produced a call that looks queued
and is not.

The rewrite guard changes shape rather than going away. A CREATE OR
REPLACE/MODIFY still rebuilds the microflow from the statement, so:

  - a script that restates every stored queue is allowed — that is now
    the normal way to edit a microflow with a queued call, and before
    this clause existed there was nothing to restate it with;
  - a script that drops one is refused, naming the clause that fixes it;
  - a stored retry policy (Queues$QueueFixedRetry / ExponentialRetry) is
    still refused outright — MDL has no syntax for it, so a rewrite
    would reset it (guard-don't-drop, ADR-0005).

The restatement walk is reflective so a newly added statement nesting
cannot silently stop being searched.

`check --references` resolves the queue name, because the build-time
failure (CE1613) is reported against the call activity rather than the
script and is expensive to trace from a build log.

Verified end to end on mxbuild 11.13.0, from a project created by
`mxcli new`:

  - queued CALL MICROFLOW and CALL JAVA ACTION: 0 errors;
  - dropping the queue produces CE1613 naming both the queue and each
    activity — which is the proof mxbuild resolves what we wrote, not
    merely tolerates it;
  - a queued Java action returning anything but Nothing is CE7038. mxcli
    defaults CREATE JAVA ACTION to Boolean, so `returns void` is
    required, not optional. Documented in the skill, the syntax topic and
    the example.

Reported as FINDINGS #24 in ako/mxcli-owid, which proposed one setter
call apiece; that would have written the inert property.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
FINDINGS #48: no OQL — well-formed or not, true or not, against a real
entity or an invented one — could make a @verify fail. It was parsed into
the TestCase and read by nothing but --list. Same silent-absence defect as
the dropped @expect, in the annotation covering the harder half of testing
a Mendix app: most microflows are side effects, so asserting on the rows
one wrote is the only way to test it.

@verify now runs its OQL against the app after the microflow returns and
keeps three outcomes apart — holds, does not hold (FAIL, with the value
that came back), cannot be evaluated (ERROR, counted with the failures).

Three things are enforced rather than left to trip you up, each learned by
running it against a real app:

  * @cleanup rollback is refused. It is the default, and it undoes the
    test's writes before the query could see them, so the assertion would
    run against the pre-test state and report a confident wrong answer.
  * The result must be one row and one column. Picking a cell out of a
    table would be a guess, and a guess here is the same silent wrong
    answer being fixed.
  * The legacy after-startup runner refuses a suite that uses @verify: its
    tests run during boot, so there is no point at which to query the app.

The expected value is split off at the last comparison operator outside
quotes and parentheses, then required to be a literal — without that check
the split silently eats a `where x = 1` and sends a truncated query.

**The OQL path itself was broken, and the first real run is what found
it.** Unit tests against a stubbed endpoint proved the logic and would
have shipped it unusable. Two bugs, both in cmd/mxcli/docker/oql.go:

  * `mxcli oql` failed on every Mendix older than 11.11. The 11.11+ REST
    route /dev/preview_execute_oql does not exist there, and the admin API
    does not 404 it — it dispatches the POST as an ordinary admin request,
    finds no "action" field, and answers HTTP 200 with {"result":1,
    "message":"Action not found"}. The fallback to the legacy M2EE action
    keyed on the 404 alone, so the legacy action — which works fine on
    those runtimes — was never tried, and the user was told to upgrade
    mxcli. Measured on 11.6.6: before, no query ran at all; after,
    `select count(*) as n from Mod.E` returns a row.
  * A rejected query was reported as 0 rows. The legacy action puts the
    error inside the feedback with a **success** result code, so
    M2EEError() says nothing and the error body parsed as an empty result.

When the dev route is absent the legacy action is the real attempt, so its
error is the one reported — otherwise an unknown entity came back as
"upgrade mxcli", which is neither true nor actionable.

Verified end to end against a booted Mendix 11.6.6 app, seven canaries
from the finding: a true @verify PASSes, two false ones FAIL with the
observed count, and four unevaluatable ones ERROR — including the unknown
entity now carrying the runtime's own message. Stubbing runVerifies to
return early puts every canary back to a wrongly green PASS.

Note for suite authors: Mendix OQL requires every selected column to have
a name, so write `select count(*) as n`, not `select count(*)`. A bare one
is an ERROR, not a pass.

Repro: mdl-examples/bug-tests/expect-vacuous-assertions.test.mdl

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
Extract ValidateProgram to unify check and exec validation
Distinguish unknown model settings from version gaps
One conflict: cmd/mxcli/syntax/features_domain_model.go, where both sides
rewrote the domain-model.entity.alter Syntax string. Resolved by taking main's
text — it carries the corrected MODIFY ATTRIBUTE form (a type is always
required) and the new DROP DEFAULT clause, and this branch's copy still showed
the wrong `MODIFY ATTRIBUTE X SET DEFAULT v` spelling — and re-attaching this
branch's RENAME ATTRIBUTE paragraph after it. Main's Example block is a superset
of this branch's, so it is taken as-is.

The generated ANTLR parser had to be regenerated for main's DROP DEFAULT rule
(mdl/visitor references ctx.DEFAULT); `make build` does that.

Also closes an unbalanced code fence in docs-site/src/language/alter-entity.md
that arrived with main: the MODIFY ATTRIBUTE example was never closed, so the
whole section down to the end of "Clearing a default" rendered as one code
block. Not from this branch's work, but visible in a file it edits.

Verified after the merge: make test and make lint pass, and both bug-test
scripts still land 0 errors on mx check 11.13.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Fix attribute rename to update XPath constraints and entity rules
mxcli test: implement @verify, and fix the OQL path it runs on
…ge key

Mapping the Combobox's `onChangeEvent` so that `OnChange:` reaches it had a
side effect: `allowedWidgetProperties` adds both a mapping's PropertyKey and
its Source, so `onChangeEvent:` became an accepted MDL property name. The
engine never reads it — `resolveMapping` for an action mapping reads the
fixed AST slot (`w.GetOnChange()`) — so the value would have been accepted
by check and dropped on write. That is the same silent-drop class this
branch set out to fix, one layer up.

An `action` mapping is now authorable by its Source only
(`Action`/`OnClick`/`OnChange`). Writing the storage key is an error that
names the spelling that works, rather than a generic "unknown property".

Caught by TestValidateProgram_ReportsWidgetPropertyError, which lands on
main from #165 and uses `onChangeEvent` as its example of a property a
widget does not have. It was right.

Also fixes the OnChange repro script, which did not build: its data view
sourced a microflow that takes a parameter and returns nothing (CE1571 +
CE0552), and bound radiobuttons/combobox to a String (CE2421 + CE7247).
Now 0 errors on mxbuild 11.13.0, with all six controls' actions stored —
the page unit carries six Forms$MicroflowAction nodes. DESCRIBE shows five
of them; the pluggable path does not read action slots, which is a separate
pre-existing gap and is stated as such in the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
claude and others added 29 commits August 17, 2026 20:44
FINDINGS #47: running the test suite dirties the project file. `git status`
reports the `.mpr` modified after a run that changed nothing, so a "run the
tests, then assert the tree is clean" CI step fails, and a pull request
carries a diff nobody can read — a `.mpr` is a SQLite database, and there
is no cheap way to tell a bookkeeping GUID from a real model edit.

The runner injects an MxTest module, builds, runs, and takes it back out.
That restore is very nearly perfect: `mprcontents/` comes back
byte-identical. The `.mpr` does not, for two reasons rather than the one
the report identified. Every unit write stamps a fresh UUID into
`_Transaction.LastTransactionID`, and the insert/delete cycle also relays
SQLite's pages. Restoring the row alone is measurably insufficient — three
consecutive runs then hold the id stable and still produce a different
file hash every time, which is how that first attempt was rejected.

So the whole file is snapshotted before injection and written back after a
successful cleanup: byte-exact by construction rather than by enumerating
what might have changed.

Two refusals are load-bearing. The restore is declined when cleanup failed,
and when the `mprcontents/` tree changed during the run. In both cases the
project is not in the state the snapshot describes, and putting the old
file back would turn a visible, harmless discrepancy into an invisible,
misleading one — a tree that reads as clean while the model is not. The
write goes through a temp file and a rename, so an interrupted restore
cannot truncate the `.mpr`; that would be far worse than the cosmetic diff
being fixed.

Verified with the reporter's own harness on Mendix 11.6.6 — three
consecutive runs against an untouched copy:

  state = mpr-hash / mprcontents-hash
  initial  5c787c16e544  0f4a84f6b489
  run 1    5c787c16e544  0f4a84f6b489
  run 2    5c787c16e544  0f4a84f6b489
  run 3    5c787c16e544  0f4a84f6b489

Before, the `.mpr` hash differed on every run. Stubbing restore() to
return early puts the snapshot tests back to failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
FINDINGS #45: the watch loop can deploy a half-applied model, and there is
no way to recover from it.

`watchAndApply` rebuilt on the first mtime bump. An `mxcli exec` of a real
script rewrites the `.mpr` and many `mprcontents/*.mxunit` files over
several seconds, so the build snapshots the tree mid-write. There was a
settle for the web bundler and nothing for the model.

On its own that was survivable — you noticed, re-ran the script, and the
second build was complete. Byte-idempotent `exec` closed that escape
hatch: re-running an already-applied script writes nothing, so nothing
re-triggers the watcher and the stale build stands, with no way out the
tool offers. Two behaviours each correct alone, composing into a state you
cannot leave.

The watcher now waits for the source mtime to stop advancing — two poll
intervals of quiet — before it builds. A long exec produces one build of
the finished model instead of a build of the first file it touched, and a
single editor save costs one extra poll. The wait is unbounded on purpose:
a long exec is the case it exists for, and building late beats building
mid-write. It still selects on the interrupt channel, so Ctrl-C during a
write is not swallowed.

`touch` on the `.mpr` remains the force-rebuild hatch, now documented
rather than folklore.

The generalisable half, recorded in the symptom table: a change *signal*
is not a change *event* — anything polling mtime samples a writer
mid-flight unless it debounces — and an optimisation that skips work
should prompt the question of what informal recovery procedure depended on
that work happening.

Note on the test: the first version asserted the written-file count after
wg.Wait(), which holds against a settleSource that returns immediately.
It reads the writer's state at the moment settleSource returned instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
…ociation storage key

Two follow-ups on top of the action-slot fix, both measured against a real
Mendix 11.13 project.

1. DESCRIBE could not see a ComboBox's OnChange, so describe -> exec DELETED
   it. The write was already correct -- the action reaches BSON as
   Forms$MicroflowAction -- but DESCRIBE read only the built-in
   OnChangeAction slot, which a pluggable widget does not use. It emitted
   `combobox cbo (Label: 'X')`, and re-executing that output wrote a widget
   with no action: 1 Forms$MicroflowAction before the round trip, 0 after,
   while the datepicker beside it survived. Read via
   customWidgetPropertyActionMap + renderClientActionMDL, the shape
   DataGrid2's onClick already used. All six controls now survive the round
   trip and the page's own Verify recipe can be completed.

2. `attributeAssociation:` is the same silent drop as `onChangeEvent:` --
   the association operation also resolves from a fixed AST accessor -- so
   the storage-key exclusion covers it too.

   The tempting generalisation is wrong and worth recording: excluding every
   storage key whose mapping has a Source rejects working syntax, because
   `optionsSourceAssociationCaptionAttribute:` DOES persist. The list is two
   operations because that is what the written documents show; a test pins it
   so the next addition has to be measured rather than reasoned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Add IN QUEUE clause for queued calls and fix OnChange on input widgets
Test runs leave the project byte-identical; --watch waits for writes to finish
FINDINGS #20: a reader grepped MISSING_CAPABILITIES.md and MDL_FEATURE_MATRIX.md,
found the External Database Connector recorded as unsupported, and designed a
whole workaround around a blocker that CREATE DATABASE CONNECTION had already
removed. The docs were wrong about far more than that one row.

Verified each claim against the grammar's statement rules and `mxcli syntax`
rather than against memory. Of the 13 document types MISSING_CAPABILITIES.md
lists as unsupported, 11 now ship. The feature matrix's "Not Yet Implemented"
table listed nine implemented features, several with a full row of N.

- MDL_FEATURE_MATRIX.md: the section now means what its heading says. Genuinely
  unimplemented types (microflow rules — not to be confused with the supported
  CREATE VALIDATION RULE — message definitions, XML schemas, SOAP, data
  importer, building blocks, extensions, widget packages, system texts) are
  listed on their own; partial ones are separated out; the fifteen implemented
  ones move into Core Document Types rather than being deleted.
- MISSING_CAPABILITIES.md keeps its measurement — how many documents of each
  type real apps contain, which has not changed — and gains a Status column, a
  per-section status line, and a header saying plainly that it is a dated
  survey and not the current status. Its execution plan is marked done.

A `database-connection` syntax topic now exists. Its absence is what made the
gap look real: `mxcli syntax` had SHOW DATABASE CONNECTIONS and nothing about
CREATE. It documents three traps, all measured on mxbuild 11.13.0, and its
example runs to 0 errors on a fresh project:

  1. connection string / username / password must reference constants, not
     literals (a literal makes the project unopenable; mxbuild stays green);
  2. username and password are required even when the driver needs neither;
  3. a named type needs its JDBC driver declared via ALTER MODULE ... ADD JAR
     DEPENDENCY, or the build fails CE5278. BYOD skips that check.

Two real bugs found while verifying, rather than assumed:

- The modelsdk (default) engine never read a query's TableMappings, so
  `describe database connection` emitted a query with no `returns`/`map (…)`
  and a describe → exec round trip dropped the entity binding and the column
  mapping. The renderer was dead code on that engine. The round-trip test
  existed but was pinned to the legacy backend, so it stayed green while the
  default engine was lossy; it now runs on both, and fails on modelsdk without
  this fix.
- `CREATE NON_PERSISTENT ENTITY` does not parse — the token is hyphenated
  (`NON-PERSISTENT`). It was documented in two syntax topics, a skill, and an
  LSP completion snippet, which inserted unparseable MDL on selection.

A drift guard pairs each capability with the syntax topic that proves it ships,
so the matrix cannot re-assert a filled gap; controls confirm both guards fail
when the exact #20 claim is reintroduced.

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

The previous pass enumerated the feature matrix by inverting what mxcli
supports. That can only ever rediscover gaps someone already wrote down.
Enumerating Studio Pro's own `Add other` menu instead — the full set of
document types the product offers — is the first check able to find a type
nobody has thought about, and it found four:

  data sets, page templates, ML model mappings, change data capture (beta)

None appeared in any mxcli document before. All 37 types were probed against
the grammar's statement rules, `mxcli syntax`, and a live project.

Two corrections in the other direction:

- Building blocks are read-only (`show building blocks`), not absent as the
  table claimed.
- Data transformers are fully authorable (createDataTransformerStatement,
  `mxcli syntax data-transformer`) and were listed nowhere at all — neither as
  supported nor as a gap.

Counted from the menu: 24 of 37 types are authorable, 4 are read-only
(layouts, icon collections, building blocks, plus pluggable widgets on the
page side), and 12 have no MDL surface.

The note now records where the row list came from, because that is the part
that decays: values can be re-derived from the code, but a missing row is
invisible until someone enumerates the product again. Re-run the pass when
onboarding a new Mendix major.

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

`mxcli -c "show page Mod.Home"` exited 0, printed nothing, and did nothing.
That is the worst available failure shape: it is indistinguishable from "the
page is empty", so it reads as a fact about the project rather than as an
unimplemented command. A parse error would have been strictly better.

The cause is not a handler bug. `showOrList PAGE qualifiedName` exists in the
grammar, but ExitShowStatement has no branch for it, so the visitor appends no
statement and the program runs zero statements.

Probing every showStatement alternative through the real parser found three in
that state, not one:

  show page Mod.Name   -> now an alias for DESCRIBE PAGE, matching how
                          SHOW ENTITY / SHOW ASSOCIATION read as their describe.
                          A missing page now reports "page not found".
  show connections     -> now lists the external SQL connections open in this
                          session (sql.Manager.List, which already existed).
                          Distinct from SHOW DATABASE CONNECTIONS, which lists
                          stored DatabaseConnector documents.
  show notebooks       -> grammar alternative removed. The notebook grammar has
                          no AST, visitor, executor or backend behind it, so
                          there is nothing to wire; it now fails loudly like any
                          other unsupported noun.

A structural grep cannot find this class. `ctx.PAGE()` DOES appear in
ExitShowStatement — for the unrelated SHOW ACCESS ON PAGE branch — so searching
for the token reports the broken alternative as covered. The guard therefore
synthesizes a probe from each grammar alternative (dropping optional groups,
which nest) and asserts a statement comes out. It covers 81 of 82 alternatives
and fails if that coverage drops.

The guard immediately earned itself: the first version of the SHOW CONNECTIONS
branch swallowed SHOW DATABASE CONNECTIONS, which also matches CONNECTIONS(),
and answered a stored-document query with live session state. Guarded with
ctx.DATABASE() == nil.

Control: reverting the visitor branch makes the guard fail on `show page`.

Still open, reported rather than swept: `create notebook X begin end` is the
same silent no-op, and the whole notebook grammar (createNotebookStatement,
notebookOptions, notebookPage, alterNotebookAction) is dead — no Go code
references any of it. Deleting a subsystem someone may be part-way through
building is the maintainer's call, not a side effect of this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
All four notebook statements were silent no-ops: `create notebook X begin
end`, `alter notebook X add page Y`, `drop notebook X` and `show notebooks`
each parsed cleanly, exited 0, printed nothing and wrote nothing. No AST
node, visitor branch, executor handler or backend method existed for any of
them — `grep -ri notebook` over mdl/ast, mdl/visitor, mdl/executor,
mdl/backend, sdk and modelsdk returns nothing.

Dead grammar is not inert. It accepts input and discards it, which is the
one outcome worse than rejecting it: `create notebook` looked like it
worked. Removed the statements (createNotebookStatement, ALTER NOTEBOOK,
DROP NOTEBOOK), their sub-rules (notebookOptions, notebookOption,
notebookPage, alterNotebookAction), and the NOTEBOOK/NOTEBOOKS lexer tokens
plus their identifierOrKeyword entries. All four now fail as parse errors.

Dropping the tokens also frees `notebook` as an ordinary identifier — one
fewer reserved word, verified with an entity and attribute of that name.

Control for a grammar change this broad: every mdl-examples script was swept
through `mxcli check` before and after and the failure SETS diffed. 15 fail
either way — all pre-existing — so the count alone would have proved nothing;
the set difference is empty. Neighbouring commands (`show pages`,
`show page X`, `show connections`, `show database connections`) verified
unchanged against a real project.

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

`RETURNS T AS $Var` names the return variable, and buildFlowGraph sets the
final EndEvent's ReturnValue to "$"+Var whenever the AS clause is present —
so the return exists whether or not the body spells one out. That is the
point of the clause. MDL003 demanded an explicit RETURN on top of it and
flagged the documented idiom as broken.

It stopped being cosmetic when `exec` began refusing any script whose checks
report an error: MDL003 is an error, so seven shipped mdl-examples scripts
could no longer run at all without --no-check.

The check now skips when returnType.Variable != "", mirroring the builder's
own condition rather than inventing a second rule. A companion test keeps the
no-AS-clause case failing, so simply disabling the rule would not pass.

Evidence the check was wrong rather than the scripts, all on mxbuild 11.13.0:
the flagged microflow builds with 0 errors, `describe microflow` renders the
synthesized `return $Found;`, and the stored EndEvent carries a ReturnValue.
End to end, 343-list-attribute-find-filter.mdl now execs without --no-check
and the project checks clean.

Control: reverting the one-line condition fails the new test with the
reported symptom.

The example sweep goes 15 -> 5. The remaining five are not this bug and are
left alone: 552-npe-reserved-words.mdl fails on purpose (it is the regression
test for reserved-word detection), and four scripts have real defects of
their own — three declare a CreatedDate attribute where the AutoCreatedDate
pseudo-type is required, and 116-datagrid2 uses a `SET WIDGET x.y (...)`
form that has never existed in the grammar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
FINDINGS #25 reported the queue-binding data loss as fixed. It was fixed on
one engine. Under `--engine legacy`, `CREATE OR REPLACE MICROFLOW` still
silently destroyed a task-queue binding — the exact loss checkNoQueuedCalls
exists to prevent, and with the same misleading signature: `mx check` goes
quiet afterwards because the configuration its CE1613 referred to is gone.

The cause is a shape difference, not a logic error. The two readers return
different Go types for the same BSON: modelsdk yields []interface{} for
arrays, the legacy mpr reader yields bson.A. bson.A is a NAMED slice type, so
`case []any:` does not match it, and the walk never descended into
ObjectCollection.Objects — it found no queued calls and allowed the rewrite.
Both walks now handle bson.A.

Also read QueueSettings back in the legacy parser. It wrote the binding
correctly but never parsed it, so on that engine `describe microflow`
rendered a queued call as an ordinary one and a describe → exec cycle dropped
it. The engines now agree on the same document, verified side by side.

Pre-existing, not a regression from the IN QUEUE work: the binary built at
4f893ce (before that merge) drops the binding under legacy too. But my commit
claimed the guard protected rewrites without qualifying by engine, and it did
not.

Verified live on 11.13, seeded binding then rewritten:
  before  legacy dropping rewrite -> "Replaced microflow", binding gone
  after   legacy dropping rewrite -> refused, binding intact
          legacy restating rewrite -> succeeds, binding intact
          describe agrees across both engines

The same []any-only assumption remains in mdl/backend/mcp/page.go,
page_mutator.go and widget_sync_apply.go; noted in the symptom table rather
than changed here, since each needs its own reachability check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
Close two silent data-loss paths, and stop the docs claiming shipped features are missing
NOTEBOOK/NOTEBOOKS appear nowhere in the grammar -- not the lexer, not
the parser, not the domain grammars -- so the committed completion list
offers two keywords the parser would reject. `make build` regenerates the
file and removes them, which left a clean checkout dirty.

Same class as the FIRST drift fixed earlier, in the other direction: the
keyword left the grammar and the generated list was never refreshed.
Regeneration is stable -- running it twice produces no further change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
…ead on the default engine

Two bugs found by asking whether the External Database Connector is fully
supported on the default engine, and testing the full surface on both rather
than answering from what was last changed. It was not.

1. A query written with a $$…$$ dollar-quoted SQL AND a
   `parameter … default '…'` stored the DEFAULT AS THE QUERY BODY. `describe`
   rendered `sql 'sales'`, the SELECT text was absent from the BSON entirely,
   and mx check reported 0 errors on both engines — nothing surfaced it.

   buildDatabaseConnection took STRING_LITERAL(0) as the SQL before falling
   back to DOLLAR_STRING. When the SQL is a dollar string, STRING_LITERAL(0)
   is the first parameter's DEFAULT. The parameter-index logic ten lines below
   already compensates for exactly this; the SQL extraction did not. Checking
   DOLLAR_STRING first fixes it.

   Isolated by bisecting the clause combination: `parameter d: String` alone
   keeps the SQL, `parameter d: String default 'x'` destroys it.

2. The modelsdk (default) engine read back neither a query's Parameters nor —
   until #171 — its TableMappings, so `describe database connection` dropped
   the parameters, the return entity and the column mapping, and a
   describe → exec round trip silently produced a query without them. The
   renderer handles all three; it only ever saw empty slices.

   #171 fixed TableMappings and missed its sibling Parameters, which is the
   characteristic version of this mistake.

Verified with a cross-engine DESCRIBE matrix — write with engine A, read with
engine B, all four combinations — because a gap on one engine is invisible
from inside that engine. All four now agree on sql/returns/map/parameter, a
describe → exec round trip is lossless, and mx check is 0 errors on 11.13.0.

Controls: reverting the visitor ordering fails the new test with `SQL =
"sales"`; the round-trip test runs on both engines and fails on modelsdk
without the reader change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
…mendixlabs#884)

LOOP and WHILE containers took their Size from measureStatementsSpan, a
pre-pass over the AST run BEFORE the body was built, so the box was a
function of statement COUNT alone. Varying only the children's positions
changed nothing:

  children at x=150/310    -> 480;160
  children at x=1500/2000  -> 480;160   (both children OUTSIDE the box)
  children at x=160/170    -> 480;160
  four children, default   -> 800;160

The model carries no geometry rules, so mx check reports nothing and only
the eye catches it. An explicit @position on a loop child moved the child
but not the container.

The box is now sized after the body exists, from the real child bounding
box (Position +/- Size/2). Same four cases now measure 420;160, 2110;140,
280;140 and 740;160 -- each tracking its contents, and case B grown to
contain children it previously excluded.

Nesting needed no extra code: an inner loop is sized when its own
addLoopStatement returns, so the outer container measures a correct inner
box (verified: inner 580;160 inside outer 1320;260).

The proposal called for build-first / size-after / TRANSLATE-once. The
translation step is deliberately dropped. A child's position round-trips
through DESCRIBE as @position, so translating the body would make a
describe->exec cycle store different coordinates than it read, and under
ADR-0008 that turns an otherwise-quiet re-run into a write. Growing the
box achieves the same containment without moving anything the author
placed -- which is what a hand-laid-out loop needs. The proposal records
this divergence.

Verified: a hand-laid-out loop round-trips byte-identically; re-running a
script is a no-op, with the MXCLI_ALWAYS_WRITE=1 control confirming the
comparison detects writes; mx check clean on both fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
External DB connector: SQL replaced by a parameter default, and a lossy read on the default engine
Both bundled rules matched on vocabulary the catalog never emits, so both
produced false positives on ordinary, correct Mendix patterns.

CONV010's ALLOWED_ACTIONS held the Mendix storage names (ShowFormAction,
CloseFormAction) while the catalog labels an action with its SDK name,
derived from the parsed Go type in getMicroflowActionType -- ShowPageAction,
ClosePageAction, MicroflowCallAction. The allowlist matched nothing, so
every ACT_ microflow that showed a page, closed one, or called a
sub-microflow was flagged. In the banking-app report that was 11 false
positives out of 13 findings, which buried the 2 real ones.

QUAL004 counted only the 'call' and 'schedule' reference kinds. The builder
also emits 'datasource', 'action' and 'calculate' for microflows and
'home_page', 'login_page', 'menu_item' for pages, so a microflow used as a
page data source was reported as 'not called from anywhere'. The page half
was masked by ENTRY_PAGE_PATTERNS, which happens to cover the pages most
likely to be navigation targets.

The guard is the point: mdl/catalog/lint_rule_vocabulary_test.go pins
CONV010's allowlist to what getMicroflowActionType actually returns, and
QUAL004's two kind lists to the RefKind constants, so a rule cannot drift
from the vocabulary it matches on again. Both tests fail against the
shipped rules with the reported symptom.

Verified on a real 11.13 project: an ACT_ microflow doing show-page plus a
sub-call and a DS_ microflow behind a datagrid are no longer flagged, while
a retrieve in an ACT_ microflow and a genuinely uncalled microflow still
are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
The Mendix model carries no geometry rules, so a loop whose children sit
outside its box passes mx check with zero errors and builds and runs
normally -- it is simply drawn wrong when the flow is opened. That is
what let mendixlabs#884 problem 1 survive unnoticed: the container's Size came from
a statement count, so children at x=1500/2000 lived in a 480-wide box and
nothing said so.

The sizing fix stops mxcli producing that, but the condition can still
arrive from a project written by an older mxcli, hand-edited in Studio
Pro, or a future layout regression. MPR011 catches it either way.

Checked in each container's OWN coordinate space, so a nested loop is
never compared against an ancestor's canvas -- the same distinction
MPR008 needed after it was found comparing a loop child against absolute
coordinates. An unpositioned child at the origin is skipped rather than
flagged, matching MPR008 and keeping the real cases visible.

Verified with a pre-fix control binary: on a project it built, MPR011
reports both escaped children while mx check reports nothing beyond the
blank app's pre-existing error. Silent on the same script through the
fixed binary, on the stock blank app, and on a nested-loop project.

The ID is MPR011, not the MPR009 the proposal named -- MPR009 and MPR010
were already taken (gallery selection listener, dataview layout grid).
The proposal is corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
FINDINGS #39 (second half) reports that `ORDER BY` on a DateTime is
ignored by the Mendix runtime: `order by DealtAt desc limit 5` returns old
rows while `order by Id desc` returns genuinely new ones, stably across
runs. The recommended workaround is to order by `Id` for recency.

Measured on Mendix 11.13.0 against PostgreSQL with statement logging on,
that diagnosis is wrong — in a way worth writing down, because the
evidence for it is genuinely convincing.

With four rows carrying distinct non-null timestamps, the SQL the runtime
sends is:

    SELECT "myfirstmodule$probe"."label" AS "label"
    FROM "myfirstmodule$probe"
    ORDER BY "myfirstmodule$probe"."dealtat" DESC
    LIMIT $1

The ordering is present, on the right column, before the LIMIT, and the
result comes back newest-first. It is not dropped, and mxcli is not in the
path either: ExecuteOQL passes the query verbatim and parseOQLFeedback
preserves row order.

Adding two rows whose DealtAt was never set reproduces the report exactly.
Mendix emits no null placement, so the database default applies, and on
PostgreSQL DESC means NULLS FIRST:

    order by DealtAt desc limit 3  ->  null-a, null-b, newest

Running Mendix's own emitted SQL by hand confirms the mechanism: identical
with the default, correct with NULLS LAST.

The trap is that the wrong answer is *stable*, so it reads as "the engine
is ignoring my ORDER BY" rather than "my sort key is empty for some rows".
The report anticipates the tie objection and answers it with "stable across
runs, so it is not a tie-break artifact" — but stability rules out
randomness, not ties. A degenerate sort key is exactly as repeatable as a
correct one.

Documented in the OQL page and the query-writing skill: use NULLS LAST on
an optional attribute rather than falling back to `order by id`, which
answers a different question (insertion order, matching recency only when
nothing backdates a row). Null placement is database-specific, so being
explicit is also the portable choice.

No code change — mxcli is not in the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
cmd/mxcli/lsp_completions_gen.go is generated by `make build` from
mdl/grammar/MDLLexer.g4 and is tracked, but the committed copy still
carried NOTEBOOK and NOTEBOOKS, which the lexer no longer defines. Every
`make build` therefore regenerated the file and left the working tree
dirty with a change nobody made.

No behaviour change: the two entries were completions the LSP offered for
keywords the parser would reject.

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

Studio Pro can "Exclude from project" any document, and Mendix then
allows a second document of the SAME NAME in the same module. Measured
on 11.13.0 with two microflows named MyFirstModule.Calc:

  one of them Excluded   -> mx check: 0 errors
  both active            -> [error] CE0122 "Duplicate document name"

So exclusion is exactly what makes the duplicate legal, and mxcli got
both consequences wrong.

Wrong target: every by-name lookup took the first match, so which
document it resolved to depended on enumeration order. `create or
replace` edited the excluded twin while the live microflow silently kept
its old body, and `describe` printed a body the app does not run.

Dropped flag: every rebuild wrote Excluded from the AST default (false)
rather than carrying the stored value, so the excluded twin became
active. One `create or replace` took the probe project from 0 errors to
CE0122 — unbuildable, from a statement that never mentioned exclusion.

Fixed for every document type that carries the flag. Lookups route
through a single selector (mdl/executor/excluded_docs.go: pickLive —
live match wins, an all-excluded set still resolves so lookups do not
start reporting "not found"), and each create path carries the stored
flag next to the ID and roles it already preserved. `@excluded` already
existed and round-trips through DESCRIBE, so an absent annotation now
means "the script does not say", never "make it active".

Four types had no Excluded field to carry at all — model.Enumeration,
types.JavaAction, types.ImageCollection, pages.Snippet — so the field is
added and populated in both engines' readers, and the backends that
hardcoded SetExcluded(false) (enumeration, snippet, image collection)
now write the stored value. Pages and snippets additionally collected
ALL name matches and deleted the extras, destroying the excluded twin
outright; the replace set is now the live documents only.

Measured, not read: queues looked correct — reader and writer both carry
the flag — and still dropped it, because the executor built a fresh
struct. A matrix run over nine authored document types found it. All
nine now survive a re-run of an unchanged script, and the original
repro rewrites the live microflow at 0 errors.

Tests fail with the reported symptoms when the fix is reverted.
A describe -> exec round-trip moved the start. A Studio-Pro-authored flow
whose StartEvent sat at 145;200 came back at 100;200, while every other
coordinate in it -- five activities, a split and two end events --
survived exactly. It was the one piece of a hand-laid-out microflow mxcli
could not preserve.

The start has no MDL statement to annotate and DESCRIBE cannot emit its
position, so the builder always derives one: (first annotated activity X)
minus one spacing unit, 260 - 160 = 100. Studio Pro's 145 is not
derivable from anything in the description.

Carried over from the microflow being replaced instead, the way the
folder and the allowed module roles already are on CREATE OR MODIFY. Nil
on a fresh CREATE, which keeps the existing derivation, so only a rebuild
of an existing flow changes. Reading the stored flow is best-effort: a
backend that cannot answer yields the derived placement rather than
failing the statement.

Verified on the full coordinate set rather than the start alone -- every
RelativeMiddlePoint in Administration.SaveNewAccount dumped before and
after, diff empty. Control with the carry-over stubbed reproduces the
100;200 drift. Re-running the round-trip script is a no-op, with the
MXCLI_ALWAYS_WRITE=1 control confirming the comparison detects writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
docs(oql): ORDER BY DESC on a nullable column puts the nulls first
fix(lint): stop CONV010 and QUAL004 reporting correct code as violations
chore(lsp): regenerate the completion keywords from the current lexer
fix(executor): keep excluded documents excluded, and target the live twin
Loop layout: size boxes from their contents (mendixlabs#884), add MPR011, keep a replaced StartEvent
@ako
ako merged commit 5a4985b into mendixlabs:main Aug 18, 2026
6 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