Skip to content

Sync ako/mxcli: round-trip fidelity, silently dropped widgets, view-entity associations, and SOAP request bodies - #1092

Merged
ako merged 30 commits into
mendixlabs:mainfrom
ako:main
Sep 13, 2026
Merged

Sync ako/mxcli: round-trip fidelity, silently dropped widgets, view-entity associations, and SOAP request bodies#1092
ako merged 30 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Eighteen commits since the last sync (#1090). Grouped by theme; every fix carries a test with a control, and each was measured against mxbuild rather than argued from the model.

One thread runs through most of it: a write that quietly loses part of what it was given. mxcli check passes, exec reports success, mx check reports 0 errors — and something the author wrote is not in the model.

Round-trip fidelity — describe → exec is the documented copy operation

•	A microflow rewrite cleared "apply entity access" and "blocking", audited across 342 microflows in 4 projects. The first is a security setting.
•	An end event with two paths reaching it produced CE0709, naming neither the microflow nor the edge — previously filed as unexplained "flow-graph drift".
•	A view entity's OQL came back in a clause order the checker could not read; the invisible half was every column rule silently stopping.

View entities can point at persistent entities — the .ID column is the declaration; one subdocument (DomainModels$OqlViewAssociationSource) takes a project from 2 errors to 0.

Accepted, then discarded — a widget with nowhere to go, a non-action in an action slot (upstream #1062), Action: NOTHING never in the grammar, an unread annotation, ALTER PAGE binding in the wrong scope.

Retiring the legacy engine — twenty-four apparent reasons to keep it measured down to seven, two of which no engine could do.

New capability — SOAP CALL WEB SERVICE can now say what to send.

Housekeeping — rule IDs collided twice in this range and the comparison is now a test; legacy no longer runs on every Mendix version in check; two bug-pattern pages re-synthesized.

claude and others added 30 commits September 11, 2026 18:19
…ements

MDL already has three other ways to call a remote operation, and the
proposal asserted consistency with them without showing the comparison.
Shown now, with the one disagreement named:

  - The argument form is `call external action`'s exactly — an OData action
    and a SOAP operation are the same shape of thing, and MDL already
    spells one `Action(name = value)`, parenthesised on the callee. That is
    also why the args belong on `operation` rather than in a clause: SOAP's
    statement target is the service, so `operation X` occupies the position
    the OData action's qualified name does.
  - `send mapping … from $var` is `rest call`'s `body mapping … from $var`,
    keyword for keyword after the noun.
  - `send rest request` diverges, and is itself the outlier: it is the only
    place in MDL where a parameter name carries a `$`. callArgumentList
    accepts both spellings, so nothing forces it, and `call microflow
    Mod.M (FirstName = 'Hello')` is what the rest of the language does.
    Matching it would spread the wart; a separate issue, out of scope here.

Also records a precedent for §2.1's derivation found in that same
statement: RestOperationCallAction stores each parameter under a qualified
key and the describer strips everything before the last dot, so MDL shows
`code` where the model holds `Mod.Svc.Op.code`. Deriving ParameterPath from
the operation's RequestBodyElementName is the same move with a different
separator.

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

CALL WEB SERVICE could say who to call and what to do with the answer, and
not what to send. Measured on ako/TestApp (11.14.0, baseline 0 errors), the
same two-statement script goes from

  [CE0178] "Body parameter mapping needs to be refreshed."
  [CE0369] "Cannot use simple request body, as the operation's body is complex"

to 0 errors, on both engines. The control is that script with the two new
clauses removed, which still gives exactly those two.

Implements docs/11-proposals/PROPOSAL_soap_request_body.md.

## One property, not two gaps

RequestBodyHandling is a polymorphic child holding EITHER the operation's
arguments (SimpleRequestHandling + WebServiceOperationSimpleParameterMapping)
or an export mapping (MappingRequestHandling). Both writers emitted an
unconditional empty Simple form, so an operation taking parameters had none
and `send mapping` — which parsed and was accepted — appeared zero times in
the written document.

That they are one property is why they land together: the mutual-exclusion
rule is only enforceable once both exist. MDL-SOAP01 refuses a statement
asking for each, and `check` and `exec` call the same function.

    $Order = call web service Clients.OrderSoapClient
      operation GetOrder (OrderId = $Customer/OrderId)
      receive mapping Clients.SoapOrdersImportMapping;

    call web service Clients.OrderSoapClient
      operation SaveOrder
      send mapping Clients.SoapOrderExportMapping from $NewSaveOrder;

Arguments reuse callArgumentList — the (Name = value) form five other call
statements already use — and parenthesise on OPERATION, matching CALL
EXTERNAL ACTION. `from $var` mirrors REST's `body mapping … from $var`.

## What made readable syntax possible

The stored key is a ParameterPath — http%3A//www.example.com/:GetOrder|OrderId
— and it is derivable: escape(operation.RequestBodyElementName) + "|" + name,
with the element read off Description.Services[].Operations[] of the imported
service. So a script names only the parameter. Escaping is per SEGMENT (a ':'
inside one becomes %3A; the separator ':' and the slashes are left alone), and
a segment containing '%' is refused rather than guessed at.

An operation that cannot be resolved is refused too, unlike the other
resolvers here, which fall back. Falling back is safe when the alternative is
the value that ships today; there is no shipping value for a path never
written, and a fabricated one reproduces CE0178 with different text in it.

## Three traps, each with a control

  - A populated ParameterMappings list leads with typed-array marker 2 and the
    codec DEFAULTS TO 3. Nothing in a build catches that; Studio Pro refuses to
    open the project. Stubbing the RegisterListMarker makes the test report
    "marker = 3, want int32(2)".
  - MappingRequestHandling's two name keys are MappingId and
    MappingVariableName. gen binds them as Mapping and
    MappingArgumentVariableName — both already in its key audit — so they are
    written raw on the codec side.
  - ContentType is "Json" on the one reference send mapping, on an XML
    protocol. Written as observed, carried through a rewrite rather than
    normalised, and flagged as needing a second reference.

## DESCRIBE, and the regression a round trip caught

The structured describe form was unreachable for every real call: they carry
fifteen keys and only nine were admitted, so Studio Pro's and mxcli's alike
rendered as base64. Admitting the other six BY NAME turned out to be wrong —
a describe → exec over the reference documents flipped Range.SingleObject
false → true with no error at all, and turned SaveOrder's DataTypes$BooleanType
result (the WSDL operation's own return type) into VoidType, i.e. CE0366 +
CE6011.

So the gate now asks whether writing the document back would reproduce it,
not whether the key is known: each boilerplate key is admitted only AT the
value mxcli writes. mxcli-authored calls describe structurally and survive a
forced round trip byte-identical; Studio Pro's keep the raw fallback until
Range.SingleObject is explained. Both engines implement this separately, so a
paired test holds them together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
ALTER PAGE REPLACE/INSERT built the new widget's attribute bindings in
the wrong scope in two shapes, both silent through `mxcli check
--references` and `exec`:

  - inside `dataview dv (datasource: selection lv)` the binding
    re-scoped to the OUTER data view's entity — CE1613 "the selected
    attribute no longer exists", and `describe page` masks it by
    printing the bare attribute name;
  - inside a Gallery/DataGrid 2 sourced by a microflow or nanoflow the
    binding was dropped — CE0402 "No value specified", `describe` shows
    `ContentParams: [{1} = <unbound>]`.

The mutator resolved a widget's scope in two separate walks that each
knew a different subset of Mendix's ten Forms$*Source kinds — the third
time that split has written a wrong binding (FINDINGS #55, mendixlabs#935).
Forms$ListenTargetSource carries no EntityRef at all, only the listen
target's NAME, so the entity walk left the context at the enclosing data
view; and the flow walk read only a widget's top-level DataSource key,
so a pluggable list — whose source sits in Object.Properties[datasource]
— was invisible to it, as was an object-list item's own widgets.

One resolver over one walk now. The ten kinds divide exactly three ways
(seven carry an EntityRef, two are flows, one borrows the scope of the
widget it listens to), so resolveSourceScope can be complete where a
per-kind patch never is. A nearer source that resolves to no entity
SHADOWS the enclosing one instead of letting it be inherited — that
inheritance is what produced a plausible wrong entity, and it also
mis-scoped a flow-sourced list nested in an entity-bound data view, a
case the report did not name.

Measured on mxbuild 11.10.0 with the new repro: 2 × CE1613 and three
`<unbound>` bindings before, 0 errors and every binding entity-qualified
after; each half proven load-bearing by stubbing it alone and rebuilding
the CLI. Note the trap that cost a wrong first reading — a CE1613
suppresses the CE0402s in the same `mx check` run.

The issue's own second repro (a Gallery over a database source) no
longer reproduced; mendixlabs#935 had fixed that one.

Fixes mendixlabs#1076

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L45JFiJ6y58ftg2zWVEq7h
`describe microflow` -> `exec` is the documented copy operation and does not
round-trip. Audited across 342 microflows in 4 projects (TestApp, CapTrack,
RestLab, Ledger; 11.14.0), two properties change with nothing reporting it —
`mxcli check` is quiet and mxbuild is quiet, because the model is valid
either way:

  - ApplyEntityAccess  true -> false   (16/342, hardcoded in both writers)
  - ShowMessageAction.Blocking  true -> false  (16 microflows, a DESCRIBE gap)

The first is a security setting. "Apply entity access" makes a microflow run
under the current user's access rules; turning it off WIDENS what it can read
and write, and the only visible consequence is different behaviour for a user
whose role should have been constraining them.

They need different fixes, which is why the report separates them by cause
rather than by symptom. ApplyEntityAccess is hardcoded false in both writers
and `microflows.Microflow` has no field for it — while `microflows.Rule`
carries it correctly on both engines, so this was never an unknown-property
gap. Blocking is carried perfectly end to end by both engines; MDL simply has
no `blocking` keyword, so the text round trip drops it.

Three claims in the first draft did not survive checking, and the report says
so rather than quietly omitting them:

  - ConcurrenyErrorMessage looked like 58 lost translations. Every one holds
    Text: "" — an empty entry versus no entry. Demoted out of the severity
    table; the writer's hardcode is still a hole, just not a demonstrated bug.
  - ActionActivity.Caption "Activity" -> "" happens only where
    AutoGenerateCaption is true. A user-set caption survives exactly.
  - CaseValues gaining Microflows$NoCase is mxcli FIXING an older shape —
    Mendix 10 rejects a bare marker as CE0079/CE0773.

The control is StableId: preserved 342/342, exactly as ADR-0008 claims. The
method normalises element $IDs away, so without a property known to be carried
there would be no way to tell a real loss from the normalisation being too
aggressive.

Also lands the tooling under scripts/microflow-roundtrip-audit/, so the
measurement can be repeated after a fix instead of described. Its README
records the three distinctions that decide whether a diff matters — layout vs
behaviour, empty vs absent, hardcoded-writer vs unspellable-DESCRIBE — each of
which already caught something here.

No code changes: this is the audit, not the repair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
fix(alter-page): resolve every data source kind in one walk
…ocking"

The two findings from the round-trip audit. Re-running it across the same 342
microflows in 4 projects shows both properties stop moving.

## ApplyEntityAccess — a security setting, cleared on every rewrite

"Apply entity access" makes a microflow run under the current user's access
rules rather than with full access, so clearing it WIDENS what the microflow
may read and write. Nothing reported it: `mxcli check` quiet, mxbuild quiet,
model valid either way.

The property now exists on `microflows.Microflow`, is read and written by both
engines, and the executor PRESERVES a stored value on a rewrite that does not
mention it. That last part is the load-bearing half — describe -> exec rebuilds
from the STATEMENT, so carrying it through the writers alone would have fixed
nothing.

The documented COPY operation (describe -> rename -> exec) has nothing to
preserve from, so MDL gained a way to say it: `@applyentityaccess` before
`create microflow` / `create rule`, `@applyentityaccess(false)` to clear it.
Absent-preserves is the same rule `@excluded` (mendixlabs#914) and the doc comment
(mendixlabs#1018) already follow, and the AST field is a *bool so silence and an explicit
false stay distinguishable. No grammar change — `annotationValue` already
accepts a literal.

A nanoflow is deliberately excluded: it runs in the client and Mendix stores no
such property, so the annotation would parse and do nothing.

Rules had the same gap from the other end — `rule_write.go` plumbed the
property through while their CREATE path never set it — and are fixed alongside.

## ShowMessageAction.Blocking — carried by both engines, lost in the text

A blocking message box halts the client until dismissed. Both engines carried
the flag perfectly; MDL had no word for it, so DESCRIBE could not emit it and
the re-parse set false. Every layer except the text was correct, which is how
it survived.

`show message '…' [type T] [objects […]] blocking [on error …]`. BLOCKING is
listed in the `keyword` rule so `blocking` remains usable as an ordinary
identifier, pinned by a test.

## Verification

  - The audit re-run: both properties gone from the changed list, 342
    microflows, 4 projects.
  - All three cases: an unstated rewrite preserves a stored true; a copy
    carrying the annotation creates one with true; `(false)` creates one with
    false.
  - Controls: reverting each of the three code changes in turn fails its test
    with the reported symptom — "ApplyEntityAccess lost on round-trip",
    "written ApplyEntityAccess = false, want true", and a describe emitting the
    message with no modifier.

Recorded in the report and not fixed here: round-tripping EVERY microflow in a
project yields CE0709 "Sequence flow is not accepted by origin or destination".
The pre-fix binary reproduces it identically, so it is pre-existing flow-graph
drift — easy to misattribute to this change, which is why the control was run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
An annotation the document does not read parsed, executed, and built at
0 errors with the annotation dropped. Three shapes, all real:

  @applyentityacces   -- a typo; the security setting is simply not applied
  @applyentityaccess  -- on a NANOFLOW, which has no such property
  @caption            -- an activity annotation written at document level

The second was created by the change that added @applyentityaccess and left
open with the note that it fails safe. Failing safe is not being reported.

## Why there was nowhere for the check to live

MDL's grammar attaches `annotation*` to `createStatement` ITSELF, so all
forty-odd create kinds accept an annotation while only six read one. Each
document's builder looks for the names it implements, so a name NO builder
implements is invisible to every one of them.

So the check goes where the parse tree is: `ExitCreateStatement` records every
annotation with the kind of document it was written on, and one validator owns
the policy. The kind is derived from the grammar's own rule names
(createMicroflowStatement -> "microflow") rather than forty type assertions,
which means a create statement added later is covered the day it is added —
defaulting to "reads no annotations" instead of being silently forgotten.

MDL059 rather than a new rule: it is the same defect the statement-level rule
already refuses, and mendixlabs#884's reasoning is unchanged. Both `check` and `exec`
run ValidateProgram, so the two cannot drift.

## Pinned in both directions

The accepted set is a per-kind table beside knownActivityAnnotations, held to
the visitor's own string literals by a test that fails BOTH ways: a name the
visitor reads but the table omits would reject a valid script, and a name the
table lists but nothing reads would re-open the hole.

That AST scrape needs one non-obvious filter — match only EqualFold calls whose
other operand mentions AnnotationName, or it also collects the literal "false"
from applyEntityAccessAnnotation testing its own VALUE.

## Blast radius, measured before erroring on what used to be silent

A scan of every .mdl and every skill block found exactly the seven implemented
combinations and no stray annotation. `make check-mdl`, `make check-skill-mdl`
and the full suite are unchanged. Without that measurement, turning silence
into an error would have been a guess.

Control: unwiring the rule makes
mdl-examples/bug-tests/document-annotation-typos.fail.mdl pass check again.
That file carries all three refusals plus a valid microflow, so it would not
pass against a rule that simply refused every document annotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`NOTHING` is the documented spelling for a deliberately inert widget — a
decorative button, a card that is styled but not clickable. It appears in
docs-site, MDL_QUICK_REFERENCE.md, the synced alter-page skill and nine
mdl-examples scripts, and it was never in the grammar.

It worked by accident. `actionExprV3` had no NOTHING alternative, so the
property fell through to the generic `keyword COLON propertyValueV3` branch at
the end of widgetPropertyV3, the slot ended up holding a plain string,
GetAction() type-asserted and returned nil, and the writer's default branch
emitted Forms$NoAction — which is exactly what the author wanted.

That same fall-through is mendixlabs#1062: `Action: OPEN_LINK` with no
URL, and `Action: TOTALLY_MADE_UP`, take the identical route to the identical
dead widget, silently. The scalar cannot be rejected while the documented form
still depends on it, so this promotion comes first.

Nothing changes on disk. The form maps to the existing pages.NoClientAction,
which every backend already treats as nil, and a page written by the pre-fix
binary is reported `Unchanged page` when the post-fix binary re-runs the same
script — the canonical comparison in ADR-0008 saying the documents are
semantically identical. Verified end to end on Mendix 11.12.0: exec, describe
round-trip, and `mx check` at 0 errors.

buildClientActionV3's switch ends in a `default:` that refuses unknown action
types, so the grammar and the builder have to move together: promoting the form
without its case would turn a working spelling into an exec failure. Removing
the grammar alternative does not even compile (`actCtx.NOTHING undefined`),
which is the control for that coupling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEZmExJUvn2nWTWE9mrd4i
mendixlabs#1062: `Action: OPEN_LINK` (a real keyword short its argument)
and `Action: TOTALLY_MADE_UP` (a token that was never a keyword) both produced
Forms$NoAction — a widget that rendered, carried its caption, and did nothing.

Measured on Mendix 11.12.0, three pages in one script: `mxcli check` passed,
`exec` reported "Created page", `mx check` reported 0 errors, and `describe
page` came back with no action on the widget at all, the token appearing
nowhere in the unit. Nothing downstream could catch it, because a no-action
widget is legal Mendix — only mxcli saw the author ask for an action.

`Action:`, `OnClick:` and `OnChange:` each carry actionExprV3, and when the
value does not match it ANTLR falls through to the generic property branch
rather than failing. Any non-*ast.ActionV3 left in one of those slots therefore
means the action expression did not parse, and is now an error at check time —
which also refuses it at exec before anything is written. `OnClick:` is looked
for under its own key as well as "Action": the alias only collapses onto
"Action" once it has parsed, so the broken spelling keeps its own.

An error rather than a warning, unlike the neighbouring "silently dropped"
rules (MDL-WIDGET20/21/23): those fire on values that are valid MDL the writer
happens not to route, while this one fires on text that failed to parse as the
thing the slot accepts. ALTER PAGE already refused the same value ("Action
value must be an action expression") — CREATE PAGE was the inconsistent one.

The rule only became possible once NOTHING was a real action expression (the
previous commit); before that it would have flagged documented, shipped syntax.
Swept every action value in mdl-examples first: `nothing` was the only
non-grammar scalar in use, 9 occurrences, and `make check-mdl` passes over the
whole corpus.

Controls: stubbing validateWidgetActionSlot fails 7 of the new tests with the
reported symptom (0 violations where the fault is), and the NOTHING cases go on
passing, which is what separates the two halves.

Third appearance of this class — SIGN_OUT and OPEN_LINK both used to reach
Forms$NoAction through the writer's default branch (CapTrackV2 FINDINGS §10);
this is the first at the parser layer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEZmExJUvn2nWTWE9mrd4i
Report a non-action in a widget's action slot (MDL-WIDGET28)
…was never writable

Five widget constructs sent a user to `MXCLI_ENGINE=legacy`, which is one of the
reasons the legacy engine cannot be retired. Measured against sdk/pages the
reachable set was five, not the twenty-three a name scan suggests: nineteen
widget structs are constructed by nothing, and EntityPathSource /
ShowHomePageClientAction are written by NEITHER engine, so their fallback named a
path legacy could not take either. None of the five was covered by the doctype
gate — no example or skill uses those keywords — which is why the suite ran green
while four plain widget keywords failed on the default engine.

Four are now written by the modelsdk engine: dropdown (Forms$DropDown),
staticimage (Forms$StaticImageViewer), dynamicimage (Forms$ImageViewer), and the
nanoflow list/grid data source (Forms$NanoflowSource, built raw because gen binds
the nanoflow name directly where Studio Pro nests it in Forms$NanoflowSettings).

The fifth was not a gap. `statictext` writes Forms$Text, and Mendix has no such
type: BOTH engines produced a project that could not be LOADED —
TypeCacheUnknownTypeException, which stops `mx check` and Studio Pro before any
validation, so the page is unopenable and unfixable in the modeler. It is now
refused by the builder and by `mxcli check` (MDL-WIDGET27), naming dynamictext.

The parity bar started as "match sdk/mpr" and measuring overturned it. ako/TestApp
carries three Studio-Pro-authored Forms$StaticImageViewer widgets (FeedbackModule),
and legacy disagrees with all three: it omits AlternativeText, which
generated/metamodel declares non-optional on both image types, and writes BSON
null for the unset Image — measured 0 nulls against 4,400+ empty strings over 40
(type, property) pairs, so an unset by-name reference is "". Legacy's dynamic
image was worse: a hand-rolled AlternativeText carrying a FallbackValue key that
Forms$ClientTemplate does not have (Fallback / Parameters / Template), four lines
after a comment in the shared serializer saying exactly that. sdk/mpr is corrected
to match rather than pinned as truth, so both engines assert one shape.

Verified: the widget mxcli writes is key-identical to the three Studio Pro
references on both engines; the project loads under mxbuild 11.14.0, where the
only remaining errors are Mendix's own (CE0582 React deprecation, CE0436 no image
selected, CE0489 no image data source — none authorable in MDL), identical on both
engines. Each fix has a control: reverting it makes its test fail with the
reported symptom.

Two divergences are deliberately left: modelsdk writes TabIndex/Width/Height as
int32 where Studio Pro writes int64, and omits an empty Widgets list — both
project-wide and predating this change, measured on 265 Forms$DivContainer
widgets. A third resolves in modelsdk's favour: legacy writes marker 2 for a
non-empty Texts$Text list, against 3 in all 3,257 Studio Pro holders.

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

Nineteen of FullBackend's 276 methods were left to the errUnimplemented stub,
whose message tells the user to rerun with MXCLI_ENGINE=legacy — the engine being
retired. That reads as nineteen reasons legacy has to stay shipped. Measured, it
is two.

Grep cannot settle this: `b.reader.GetRawUnitByName(...)` inside the MPR backend
and `ctx.Backend.GetRawUnitByName(...)` in the executor look identical to a regex
and receiver names vary. scripts/backend-reachability.sh asks the compiler
instead — remove one method from its interface, rebuild, and see whether anything
fails. 17 DEAD, 2 LIVE.

GetRawUnitByName and ParseMicroflowBSON are called from four sites in
mdl/executor/cmd_microflows_builder.go (lookupMicroflowReturnType,
lookupNanoflowReturnType, microflowExists, nanoflowExists). Nothing was broken by
their absence and no test failed, because each is a fast path with a working
fallback: the stub errored and the code fell through to an O(n) walk of the
module's microflows, producing the same answer. That is why this went unnoticed,
and it is the shape to look for when auditing what an engine cannot do.

Both are now implemented. GetRawUnitByName and its sibling GetRawMicroflowByName
are one-line delegations — the codec reader already indexes by name. ParseMicroflowBSON
needed real work: the callers pass NANOFLOW contents to it as well, which the
legacy parser only survived because it walks an untyped bson map, while the codec
decodes to two different gen types.

No speedup to report. Restoring the fast path measured 620ms against 608ms over
three runs on a 138-microflow project — within noise, because `mxcli exec` runs
`check` first and check's own helpers load ListMicroflows anyway, so the cache
the slow path builds is already warm. The value is the removal of the
errUnimplemented cliff on the default engine.

The 17 dead methods are left in place rather than purged: they are surface, not
dead code paths — implemented and working on the MPR backend — and deleting them
touches six files for no behavioural change. TestNoReachableUnimplementedBackendMethods
pins the probe's output instead, with the reason each was found unreachable, so a
new stub or a dropped implementation fails there and prompts a re-run of the
script rather than an extension of the list on faith. Its control asserts the two
live methods stay implemented, or the test would pass just as well against a
build where nothing is.

Controls: removing either implementation makes its test fail with
"not implemented yet — rerun with MXCLI_ENGINE=legacy"; un-implementing a
listed-dead method makes the guard name it and print the script to run; a stale
entry in the list is reported too.

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

Two things, both settled by measurement.

**MDL-WIDGET27 was already taken.** The `statictext` refusal added in 141da93
reused an ID that validate_widget_object_property.go owns — and MDL-WIDGET28,
the next number, is taken too by a validator that is on main but not on this
branch, which is 18 commits behind. It is now MDL-WIDGET29. A rule ID is
allocated globally and was being picked against a local view; `git grep` on the
working tree would have caught the first collision and could never have caught
the second.

TestRuleIDHasOneOwner guards the half a repository can see: an ID raised from two
different validator files fails, with the reminder to check the default branch
rather than the working tree. The invariant is one OWNER, not one occurrence —
MDL-WIDGET25 legitimately fires from two branches of its own validator, so
counting occurrences would be a false-alarm generator. Adding it immediately
found a second case, MDL059, reused earlier the same day for document-level
annotations; that one is a genuine extension of one rule to a second site
(someone suppressing MDL059 means both), so it is recorded on the exemption list
with that reason instead of renumbered. MDL-WIDGET21's pre-existing overlap is
recorded the same way: renumbering a shipped rule changes output, json/sarif and
suppressions, so an existing overlap is documented rather than churned.

**Legacy now runs on one Mendix version in nightly, not five.** The engine matrix
is most of what that step costs — each script is executed and handed to mxbuild
once per engine (measured 588s -> 232s for modelsdk alone) — so legacy was five
sixths of the fleet's cost for a path nothing routes to any more: the last four
widget keywords are written by the codec engine (141da93) and the last two
reachable unimplemented backend methods are implemented (a139d38). It is
strictly weaker now, refusing rules, menus, layouts, message definitions and
regular expressions.

Narrowed rather than dropped, deliberately. `--engine legacy` is still something
a user can type, and an engine that ships untested rots silently — the failure
this repo has already had once (mendixlabs#808, an integration test that had only ever
skipped). One version keeps it honest at a fifth of the cost; that line goes when
the engine does. Legacy's unit tests are untouched and still run on every push.

Evidence it still passes: the last eight nightlies are green, the most recent
this morning, each running the full engine matrix across all five versions.

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

90 of the 123 findings undigested since the 2026-08-31 bug-pattern sync are
mdl/executor, and most are one of these two classes.

check-mxbuild-drift gains three sections:

- Three answers, not two. A check needs resolved / not-resolved / "could not
  establish", where exec needs two — turning "I could not look" into "your
  attribute is missing" is a false error that blocks a script which builds
  cleanly. Four findings measured that false positive independently. Includes
  that check normally runs with NO project, which is also the CI default, and
  the allow-list/deny-list choice for rules keyed on a type.
- Don't model what you can run. The mutator-probe technique: run the real
  setter against a throwaway deep copy whose Save is refused, keep only the
  error, so check and exec cannot drift.
- The instruments lie in specific ways. One error per microflow hides the one
  being measured; a load failure suppresses the error-count line while still
  exiting non-zero (third occurrence); the corpus sweep has a noise floor and
  is blind to a misparse, because it diffs diagnostics rather than the AST.

Plus a remedy beat: two rules shipped whose Fix: line walked a working project
into a broken one.

widget-type-object-drift was the oldest and thinnest page in the category and
lacked the framing that makes CE0463 tractable: that two unrelated bugs wear
the error (package upgraded after authoring, versus authored-fresh-and-wrong)
and the two controls separating them; that `mxcli docker check` runs
mx update-widgets first and so reports 0 errors on a project that has one; that
a difference from the reference is not a cause. sources: adds
diagnose-ce0463.md (written after the last sync) and replaces the bare findings
directory with the four shards that carry the records.

Also normalises the `area` field on two findings records that used the shard
filename (mdl-executor) where the other 677 use the path (mdl/executor).
check-findings accepts either — it requires the field, not a vocabulary — so
digest-status had bucketed both into its "areas < 5" row, which is the report
used to choose this run's scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
Five things that were silently wrong, and the last three reasons legacy had to stay
…m at

A `describe microflow` → `exec` round trip of all 41 microflows in ako/TestApp
produced a project mxbuild refuses: CE0709 "Sequence flow is not accepted by
origin or destination", naming neither the microflow nor the edge. `mxcli check`
passed, `exec` reported success, and the project still opened in Studio Pro — the
document is well formed, just over-connected — so only a build caught it. It was
filed as unexplained "flow-graph drift"; this is the diagnosis.

A Mendix end event accepts exactly ONE incoming sequence flow. Joining two paths
is what a merge is for. An empty `on error … { }` handler inside a branch whose
sibling also returns gave one two:

    if … then
      $r = call microflow M.Sub() on error without rollback { };
      return $r;          -- the normal path reaches the end event
    else
      return 'no';        -- and so does this one
    end if;

Measured on the one microflow that tripped it,
FeedbackModule.SUB_Feedback_SendToServer: stored, two end events with in-degree 1
each and five merges; after the round trip, one end event with in-degree 2 and
one merge. The colliding edges are the else branch's return and the error-handler
flow out of "Post feedback to App Insights".

Fixed as a post-pass over the finished graph, beside applyFlowCurves, not as a
guard where the error flow is wired: the two flows come from unrelated builders
in either order — here the error flow lands FIRST and the branch's flow arrives
after — so neither creation site can see the collision. A first attempt at the
wiring site compiled, ran, and changed nothing, which is what sent me looking.

A merge and not a second end event, because that is what Studio Pro writes here
and because the microflow has one return value that a second end event would
need its own copy of. Scoped to end events: other nodes also take one inbound
flow, but an end event is the only one measured to collide, and rewriting nodes
with real inbound semantics on no evidence would be a guess.

Whole-project round trip after the fix: 0 errors, from 1.

The test parses real MDL through visitor.Build rather than hand-building the AST
— the hand-built equivalent puts the two paths on separate end events and does
not reproduce the bug at all, which is itself worth knowing. Controls: a handler
with a statement in it, and a plain if/else, must gain no merge; stubbing the
post-pass fails the first test with the reported symptom ("end event … has 2
incoming sequence flows, want 1").

Not fixed, and now recorded as a separate gap: DESCRIBE cannot say "the error
flow rejoins the main path here", so a round trip still flattens Studio Pro's
merge chain. That is a cosmetic diff rather than an unbuildable project, and
closing it needs new MDL syntax.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Phases 1 and 2 were gated on a prevalence scan that had never been run, so
the proposal had sat at "unscheduled" since 2026-08-20 with its own outcome
table unable to fire.

Scanned 555 microflows across ten Mendix-authored Marketplace modules
installed into one 11.6.6 project. Marketplace code rather than demo
projects, because a content id and a version reproduce the corpus exactly
where two numbers pasted into a doc do not. All 44 findings are committed
as data so the table can be recomputed instead of trusted.

5.8% of all microflows are irreducible, 12.5% of the ones that branch, in
7 of 10 modules -- and 80% of the findings are recombinable. That is the
table's middle row: Mode 3 earns its cost, with Mode 2 the honest fallback
for the ~20% that stay crossed. Scheduling stays a separate call.

Two limits recorded with the numbers, both of which bound the claim:
LintContext.Microflows filters through notPlatformModule, so the shipped
lint rule never sees this corpus at all -- the coverage that does reach it
is the describe-time warning, and prevalence in a user's own modules is
still unmeasured. FullMicroflow also returns nothing for RULE- and
NANOFLOW-typed flows, so rules are outside the detector's reach despite
being able to branch.

Control, since a rule that never runs and a rule that finds nothing look
identical: the scan reported what it examined (seen=599 loadfail=44
analysed=555), and the 44 failures reconcile exactly with the catalog's 39
NANOFLOW + 5 RULE rows. Discrimination is shown by a pair --
Administration.ManageMyAccount with one branching split is flagged and
warns on describe, Email_Connector.VAL_EmailTemplateRecipients with ten is
silent on both -- so the detector keys on structure, not on branch volume.

Also corrects the scan command: the flag is -r/--rules, not --rule, and
status drops from draft to partial now that Phase 0 is merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
docs(wiki): re-synthesize check-mxbuild-drift and widget-type-object-drift
Give an end event that two paths reach a merge to join them at (CE0709)
Measure MDL-FLOW01 prevalence: 12.5% of branching microflows are irreducible, 80% recombinable
…p dropping a widget with nowhere to go

Two defects from ako/view-entity-examples FINDINGS, §3 and §2. Both are the
same shape: mxcli goes quiet on input it cannot place, so the only symptom is
something missing later.

§3 — a view entity's OQL in the FROM-first clause order

Mendix OQL puts the select list in either position and the MDL grammar accepts
both (oqlQueryTerm). extractSelectClause handled only select-first: it found
SELECT and scanned FORWARD for FROM, so in `from … group by … select …` the
terminator was behind the keyword and the scan ran off the end, returning "".

That order is what Studio Pro stores, so it is what DESCRIBE ENTITY emits —
describe → edit → check was the one loop that broke on this document type.

The empty string did two things and only one was visible: inferOQLTypes
reported "could not parse select clause", while MDL030, MDL072 and every
MDL031 type rule — all behind `if selectClause != ""` — silently stopped
running. A from-first view got no OQL checking at all.

So the fix is both halves. extractSelectClause now reads either order, and an
unreadable select clause is REPORTED rather than skipped, because this is a
recurrence at the same function: bug 9b (2026-07-09) was a case-comparison slip
with the identical symptom and the identical hidden half. Two widening traps
are pinned by tests: the orders need different terminator sets (admitting
ORDER/LIMIT in select-first cuts `select o.Limit as Limit from …` in half), and
the scanner skips quoted runs, since `s."Order"` is the form mxcli tells users
to write so a reserved word survives CE0174.

§2 — a child a pluggable widget has nowhere to put

The four passes that place a pluggable widget's children each `continue` past
what they do not recognise, and applyChildSlots parks leftovers in
defaultWidgets, which is assigned only if the widget declares a `template`
slot. A Gallery has one; DataGrid 2 does not, so its leftovers were built and
discarded — check silent, exec successful, build clean, and DESCRIBE PAGE
showing the widget gone as the only symptom.

The reported case needed two mistakes, and the docs supplied one: `filter` is
the Gallery keyword for the filters placeholder (DataGrid 2 spells the same
property `controlbar`), and `widgetV3` is `type name props? body?`, so a filter
written after a column's parentheses is a sibling widget rather than a block on
the column. `mxcli syntax page widgets` documented exactly that form, so the
likeliest thing to write was the thing that vanished; it now shows the working
one.

MDL-WIDGET29 reports it at check time and buildPluggable refuses it at exec
time, both from one predicate so they cannot disagree. The predicate is
conservative — silent with no definition, with no child slots, or when a
`template` catch-all exists — so check can never claim a drop exec would not
make. The message names the spelling this widget uses for the same slot,
derived from the table the engine already reads. Because that definition is
read from the project's installed .mpk, upgrading Data Widgets from the
Marketplace keeps the rule current with no mxcli release.

Verification

Both proven to be the cause by reverting them. §3: the clause-order tests fail
with the reported "" and with `got []` where the rules had gone quiet. §2,
measured end to end on Mendix 11.13.0 with DataGrid 2 (Data Widgets) 3.4.0 —
with the guard stubbed, `exec --no-check` printed "Created page", exited 0, and
`describe page` came back with the column and no filter at all, reproducing the
report against a real model; with the guard in place the same script is refused
naming `controlbar`, the column-braces form persists as
`column … { textfilter tf }`, and `mx check` on the result is 0 errors.

Controls throughout — each from-first query paired with its select-first twin,
and the real Gallery definition, on which the same `filter { … }` block must
stay valid.

go test ./... clean; make lint clean; make check-mdl 521 pass, 0 fail;
make check-findings OK.

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

`describe widget` is where MDL-WIDGET26 and MDL-WIDGET29 send an author to find
out what a widget's body takes, and where the quick reference and the syntax
topics point too. For a Data Grid 2 it did not answer the question that prompted
those rules.

describeContainers looped an object list's ItemProperties and never its
ItemSlots, so a column's two widgets-typed slots — `content` and `filter` —
were invisible, even though the property dump higher up in the same output
shows them as `widgets`. The generated MDL example had the matching hole:
`column item1 (showContentAs: 'attribute')` with no body, so nothing suggested
a column takes one.

The result was worse than an omission. The only filter-shaped container it
named was `controlbar` — the grid-WIDE filter bar — so an author asking this
command where the column filter goes was steered to the one place that renders
"Unable to get filter store. Check parent widget configuration."

Now:

  column        object list  -> columns  authorable
                  items: showContentAs, attribute, dynamicText, …
                  slot content -> content: any other widget in the item body
                  slot filter  -> filter: textfilter | numberfilter | datefilter | dropdownfilter

and the example shows the column body with a filter in it. What matters is the
second half of each slot line — how a widget REACHES the slot — because
AcceptedChildTypes is exactly what makes the filter go in the column's own
braces with no wrapper.

Both the accepted types and which slot is the default are read from the engine
(itemSlotAcceptedChildTypes, defaultItemSlotKey) rather than restated: a second
copy of the routing rule is the mendixlabs#1036 defect one layer up, and a test compares
the described list against the engine's table so they cannot drift.

Measured against Mendix 11.13.0 with DataGrid 2 (Data Widgets) 3.4.0. Controls:
a widget whose containers are plain child slots (Gallery, Timeline) reports no
item slots; stubbing the loop fails all four tests with `got map[]`; and the
generated example still passes its own parses-as-written guard, verified
separately with `check -p` at 0 errors.

go test ./... clean; make lint clean; make check-mdl 524 pass, 0 fail;
make check-skill-mdl clean; make check-findings OK.

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

Selecting a persistent entity's id under an alias gives a view entity an
ASSOCIATION to that entity, named after the alias. The id column is not one of
the view entity's attributes, and there is no separate declaration anywhere:
Studio Pro creates the member when the column is added, so the column IS the
declaration.

mxcli modelled the select list as attributes only, and got three things wrong
at once (ako/view-entity-examples FINDINGS §1):

  1. `check` aligns columns with declared attributes BY POSITION and did not
     skip the id column — so it did not merely go unchecked, it SHIFTED every
     attribute after it onto its neighbour's expression. With the id first:
     "attribute 'TotalKwh': declared as Decimal but OQL expression returns
     Integer". With it last: "OQL select has 4 columns but 3 attributes
     declared". Neither describes the script.
  2. `exec` created no association member, so the OQL and the model disagreed —
     CE1613 on 11.13/11.14, CE6770 on 10.24.
  3. Adding one by hand wrote `Source: null`, which mxbuild refuses with CE6771
     "It is not possible to create associations to/from View Entities".

The measurement came first, before any Go. Build the broken model with mxcli,
apply the reporter's python patch that adds only

  "Source": { "$Type": "DomainModels$OqlViewAssociationSource",
              "Reference": "<the OQL alias>" }

and re-run mx check: 2 errors to 0 on 11.13.0. That pinned the target shape and
showed that ONE field clears both errors, because the association is also what
makes the id column legal on the view entity.

So the association is derived from the OQL rather than given new syntax, which
is also what makes describe → exec round-trip: the OQL carries the column, so a
described view entity rebuilds its association with no second statement to keep
in step. A plain `create association` with a view entity at either end is now
refused at check AND exec time, pointing at the column form instead of the
pre-fix workaround. The alias must be free in the module, case-insensitively —
Mendix's "Duplicate name" rule, which the reporter found the hard way.

Three things the report did not say, found by running its own example:

- It calls the stored element a CrossAssociation. That is true only when the
  target is in another module; same-module is a plain Association. Both needed
  the field, so a fix pinned to one would have worked for one project layout.
- The read path is not optional. `create or modify association` round-trips
  through the semantic model and wrote Source back as null, silently converting
  a working project into CE6771. (The ALTER path does not, because it
  gen-mutates the stored document — so the loss is narrower than the report
  implies, but real. A/B'd both ways.)
- `extractAliasMap` matched neither `join r/Mod.Assoc/Mod.Target as m` nor
  `from Mappings."Order" as o` — an association-path join and a quoted reserved
  word, which between them are the reporter's literal example. The alias
  resolved to nothing and no association was created at all.

`cast(m.ID as string) as MeterId` is deliberately NOT an association: it is a
legitimate flat design (one query instead of two, no objects materialised in the
client), so detection is the bare `<alias>.ID` form only, with a control test.

Also collapses scriptContext's two parallel collectors into one. They were kept
in step by hand and were not: collectSingle had no CreateConstantStmt case, and
adding view-entity tracking to one left the other silent — so the new CE6771
rule passed against a project and did nothing on a one-script repro, which is
the ordinary shape.

Measured on Mendix 11.13.0, both engines, 0 errors: the reported same-module
case, the reporter's cross-module quoted-entity case, a describe → exec into a
fresh project, and a re-run (Unchanged). Controls: the pre-fix binary on the
same script gives 2 × CE1613, and the pre-fix binary drops the patched Source
on rewrite.

go test ./... clean; make lint clean; make check-mdl 525 pass, 0 fail;
make check-skill-mdl clean; make check-findings OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
… guard the numbers

MDL-WIDGET29 was taken twice. This branch used it for the unroutable-child rule;
main independently used it for the retired-spelling rule. Both were correct when
written — nothing compared them until a rebase put both in the tree, and the
number is only ambiguous once they meet.

Renumbered this branch's rule to MDL-WIDGET30 and added the comparison as a
test, because no amount of care at authoring time prevents a merge collision:
the guard has to run where the collision exists, which is CI on the merge.

The invariant is not "one file per id" — MDL-WIDGET21 is legitimately raised
from two paths and is still one rule — so known pairs are listed and anything
new fails, which is the moment to decide whether it is one rule or two. A
second test keeps the numbers dense, so "the next free one" has an answer.

Proven by reintroducing the collision: the guard names both files and says what
to do. Its first version scanned for `RuleID: "…"` and missed MDL-WIDGET22,
which is declared as a named constant — so it reported a gap that was not there.
It now matches quoted literals, which is also what separates a declaration from
a doc comment naming a neighbouring rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
View entities and widget bodies: three defects from ako/view-entity-examples
@ako
ako merged commit dcbe7ad into mendixlabs:main Sep 13, 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