Skip to content

Sync ako/mxcli: REST request bodies, mapping fidelity, idempotent writes, and platform fixes - #938

Merged
ako merged 39 commits into
mendixlabs:mainfrom
ako:main
Aug 20, 2026
Merged

Sync ako/mxcli: REST request bodies, mapping fidelity, idempotent writes, and platform fixes#938
ako merged 39 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

REST request bodies (5 commits) — the import-range runtime failure, body: file sending the expression text, binary POST becoming expressible, the export-mapping body's three wrong properties, and file-document responses (#922)
Mappings and describe fidelity — multi-segment member paths (#927), MDL-MAP01, irreducible-graph description (#923)
Idempotent writes — $ID preservation, skipped-write reporting
Platform — mxbuild architecture refusal, Windows .exe and per-user JDK
Pages, widgets, security — contentparams (#928), alter page column targets, and GRANT widening rather than replacing an access rule
Contributor tooling — the two session hooks

claude and others added 30 commits August 18, 2026 19:19
A "Call REST service" activity configured to store its response in a file
document had no MDL form at all, and both readers fell through to String.
DESCRIBE reported `returns String`, and the describe -> exec round trip then
wrote that back. Measured on mxbuild 11.6.6:

  stored before:  ResultHandlingType = FileDocument
                  VariableType       = ObjectType(MyModule.MyFile)
  stored after:   ResultHandlingType = String
                  VariableType       = StringType
  mx check:       unchanged, only the project's pre-existing baseline error

So the activity was silently retyped with a valid model and a green build --
nothing downstream could notice. On the legacy engine the output variable
went too, because the unread result handling took the `$var =` fallback with
it.

The two readers failed differently, and each looked fine from inside itself:
the legacy parseResultHandling had no FileDocument case and returned nil,
while modelsdk's restResultHandlingFromRaw read VariableType.Entity and then
discarded it, keeping only a literal match on System.HttpResponse so that
everything else -- FileDocument AND any other object type -- became String.
It now reads Mendix's own ResultHandlingType discriminator, falling back to
the VariableType because that property is omitempty.

Syntax is `returns Module.Entity`, added as the LAST alternative of
restCallReturnsClause so the keyword forms keep winning; a test pins that.
Mendix rejects the BASE System.FileDocument as a return type (CE0362), so
the entity is always a specialization -- which is possible because CE1540
lists FileDocument among the four System entities that may be specialized.
MDL064 reports the base type and an unqualified name before the write.

The other half of the report needed no change. `returns response`
round-trips losslessly, and the suggested "HttpResponse specialization"
cannot exist: CE1540 permits only User, FileDocument, Image and Paging, so
`response` already names the only type such a result can have. Verified, and
pinned by a round-trip test so a future change here has to stay honest.

The silent String fallbacks in the describer are removed. A result handling
mxcli cannot reconstruct now renders as text the parser REJECTS, naming what
was unsupported: a describe that fails is recoverable, one that quietly means
something else is not (ADR-0005). A test asserts the refusal does not parse.

Verified: round trip on BOTH engines (gateEngines) -- the check that finds
this class, since either reader alone looks consistent; the authored activity
builds at baseline under mx check; 338 shipped examples scanned with 0 new
hits; 19 microflows described on both engines with no spurious refusal; unit
tests confirmed to fail with the visitor branch and the renderer case stubbed.

Also adds `mxcli syntax rest.call`, which documents the activity and all five
RETURNS forms -- there was no topic for it before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
REST calls can store the response in a file document (mendixlabs#922), and DESCRIBE stops guessing String
The nightly failed on the Mendix 10.24 matrix entry, on both engines, in
TestMxCheck_DoctypeScripts:

  Execution error: this project does not store the model setting DecimalScale

14-project-settings-examples.mdl set it unconditionally. Measured against a
blank project of each version: 10.24 stores 11 model settings, 11.6.6 stores
12, and DecimalScale is the only difference. Executing the other five
settings from that statement one at a time on 10.24, each is accepted -- so
DecimalScale alone is at fault, and because the refusal covers the WHOLE
statement it took five portable settings down with it.

mxcli's refusal is correct and is not changed here: Studio Pro will not open
a model carrying a property its version does not define, and mxbuild does not
catch it. The example was simply not version-gated, despite its own comment
three lines above explaining that which settings a project stores depends on
its version.

Also adds TestDoctypeScriptsParseAfterVersionFiltering, which filters every
doctype script for each version in the nightly matrix and asserts the result
still parses. It needs no mxbuild, so a mis-gated script fails in seconds on
push instead of hours later in one nightly job.

Writing that guard found the trap that makes this easy to get wrong, and the
first attempt at this fix walked straight into it: a `/** */` block is a
DOCUMENTATION comment bound to the statement after it. Gating the statement
while leaving its doc comment outside the section orphans the comment, and
the script dies with "no viable alternative at input '/**...'" -- reported at
the NEXT statement, tens of lines further down, so it reads like an unrelated
syntax error in code nobody touched. The comment now sits inside the gated
section; `--` line comments are free-standing and safe either side.

Verified: the full doctype suite (59 scripts, both engines) passes on 10.24,
where it failed before; the changed script passes on 10.24, 11.6.6 and
11.13.0; the control -- the pre-fix file -- still reproduces the reported
error on 10.24; and the new guard, with the doc comment moved back outside
the gate, fails on exactly the 10.24 entry and passes once restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
Fix the 10.24 nightly: version-gate DecimalScale, and guard the doctype corpus against mis-gating
Studio Pro's version-control view showed an entire nanoflow as changed after
editing one argument of one JavaScript action call, and the same one-literal
change to a microflow read as a wholesale replacement. Measured on the
reporter's project: 36 of 37 element identities re-minted in the nanoflow, 21
of 22 in the microflow. The damage was cumulative — changing a value and
changing it back produced a semantically identical document sharing none of the
original's element IDs.

Elision (ADR-0008) never covered this. It answers "did anything change?" and
keeps a no-op write off disk; when something *did* change the document was
still rebuilt from the MDL, and every sub-element of a rebuild gets a freshly
random $ID.

canon.TransplantIDs matches the incoming document against the stored one
element by element and puts the stored $ID back on every element that still
corresponds. Alignment is by $Type plus the shape one level down
(Action=Microflows$LogMessageAction) plus Name where there is one, LCS-anchored
within each list with positional fill in the gaps — the deeper key is what
tells two otherwise identical ActionActivity wrappers apart when an activity is
inserted, without which the newcomer inherited its neighbour's whole subtree.

References are rewritten in the same pass, which is the rule PR #125 broke: a
pointer is a primitive binary property that a containment walk never sees. The
substitution therefore covers every 16-byte binary in the document rather than
a maintained list of pointer property names, on canon's own insight that any
occurrence of one of the document's element IDs is a reference by definition.
It is applied in place on a copy, so a fixed-width binary keeps the framing
intact and nothing is re-marshalled.

The correctness bar is lower than it looks and the code says so: a wrong match
only makes a diff bigger, because every reference moves with its element and
nothing outside the unit remembers an $ID. The one real failure is two elements
sharing one, guarded by dropCollisions (run to a fixed point) and by reading
the result back and checking the id set. GUIDs are untouched — those are the
database's identity and were already preserved.

Measured, both engines: 37 of 37 identities kept on the reported nanoflow with
the BSON diff down to the one changed argument; a change plus its revert
returns to the original bytes exactly; inserting or deleting an activity mints
IDs only for the genuinely new elements (22 of 22 kept, 6 new). mx check on the
reporter's app is unchanged at its one pre-existing CE0117.

TestWriteMicroflowTwice_ControlChurnsWhenElisionOff had to change: it proved
elision was doing the work by forcing writes with MXCLI_ALWAYS_WRITE and
watching the bytes churn, and identity preservation now makes a forced write
byte-stable too. The control moves down a layer to the raw codec output
(TestRebuildChurnsSubElementIDs), and the forced-write case becomes a positive
assertion about the transplant. The shell equivalent is documented as a control
on mtimes rather than hashes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Re-running an already-applied script printed

    Modified javascript action: MxCore.JS_LoadAiAdvisor
    Replaced nanoflow: HomeScan.ONL_AIAdvisor

against a project whose files were not touched — not even their mtimes. The
elision was real and correct (ADR-0008); only the reporting was wrong, and it
is wrong in the direction that costs most: someone diagnosing version-control
churn from console output concludes mxcli rewrites everything on every run,
which is exactly how mendixlabs#910 was first mis-diagnosed.

The handler cannot tell, because ctx.Backend.UpdateNanoflow returns nil whether
or not storage kept the write. Both engines' writers now count unit writes
offered versus written at their single choke point, backends expose that
through an optional backend.WriteStatsReporter, and ExecContext.ReportMutation
downgrades the verb to "Unchanged" when writes were offered since the last
report and none of them landed.

The sampling is per report rather than per statement, so a handler that
rewrites several documents in a loop (constants, module roles) labels each on
its own merits. The verb is only downgraded on positive evidence, so a mutation
that never reaches unit storage — a theme file, a mock backend, any engine with
no notion of units — is reported exactly as it was before.

WriteStatsReporter is deliberately not part of FullBackend: it says nothing
about the model, only about what a storage engine did, and a backend with no
units has no honest answer.

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

Studio Pro can bind a leaf several levels below the object element it
belongs to: tick a nested leaf without ticking its parents and it is offered
on the nearest object element you kept. Mendix stores that as ONE
multi-segment JsonPath -- "(Object)|customer|name" on a value element whose
parent object element is at "(Object)", with nothing mapped for "customer".
One entity, values pulled from several depths.

MDL could not express it, so a generated module needed an entity per object
level purely to reach a value -- one endpoint added 21 entities, almost all
pass-throughs holding nothing but an association.

    Attr = customer/contact/email

`/` is the MDL spelling of Mendix's `|`: it reads better here, and on this
side of `=` it cannot collide with the association form on the other side.
The member is resolved one segment at a time, so every step keeps the
raw-key/exposed-name tolerance from mendixlabs#882.

DESCRIBE was mis-reading the shape, which is the half the report did not
mention. Value elements were printed as the last segment of their JsonPath
alone, so a project holding "(Object)|customer|name" described as
`CustomerName = name` -- a description of a model that does not exist -- and
re-executing mxcli's own output failed with `"name" is not a member of the
JSON structure at (Object)`. Members are now rendered RELATIVE to the
enclosing object element, on both engines and for both mapping kinds.
Nothing was ever corrupted: the mendixlabs#882 guard refused the bad re-execution.

The parent path is used verbatim when computing that relative member. An
array's object element already carries the ITEM path, so trimming the
"|(Object)" marker off it made a child of that item render as "(Object)/sku"
-- caught by the new array cases, and visible in mendixlabs#915's existing test.

Two shapes are REFUSED rather than written, each measured on mxbuild 11.13
rather than assumed:

  * An EXPORT mapping cannot collapse levels. Three-way control: the same
    member in an import mapping is 0 errors, the same export mapping with
    only top-level members is 0 errors, and the collapsed export is CE5015
    "There is no child mapping matching schema element". An export has to
    PRODUCE the intermediate node, so something must map it.

  * An import member cannot cross a 0..* element. Patched into a stored
    mapping, mxbuild answers CE0256 "Between value mapping 'sku' and parent
    element '(Object)' is a schema element with wrong occurrence (0..*)" --
    the rule is occurrence, not "array" loosely, and the message says so.

Both refusals name the build error they prevent, because each would
otherwise be valid MDL that passes `mxcli check` and fails only in the build.

Verified: the collapsed mapping builds at 0 errors on mxbuild 11.13 and
round-trips byte-for-byte through DESCRIBE on BOTH engines; a control with
the relative-member fix stubbed fails exactly the collapsed cases while the
direct-child cases keep passing; 15 shipped mapping examples still parse and
the association forms of both mapping kinds are unaffected; 76 unit packages
green.

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

CI failed on PR #188 with

  FAIL (negative test unexpectedly passed): 927-mapping-nested-member-path.fail.mdl

`make check-mdl` runs plain `mxcli check` and expects every .fail.mdl to fail
THERE, but both mendixlabs#927 refusals lived in the executor, so check exited 0.

The Makefile's own note above check-mdl describes this trap exactly -- a
working rule made to look regressed, from mendixlabs#891 and mendixlabs#892 -- and prescribes the
split, which is what this does:

  * The EXPORT refusal is purely syntactic: whether a member contains "/" is
    visible in the statement, no project required. It moves into the
    no-project pass as MDL-MAP01, beside ValidateGrantRoles, which is there
    for the same reason (mendixlabs#836). So `mxcli check` now tells the author before
    a script starts writing, and the negative fixture legitimately fails
    check.

  * The array-crossing refusal genuinely needs the project's JSON structure
    to know an intermediate is 0..*, so it cannot fail check. It is out of
    the .fail.mdl and covered by the array case in
    TestJSONSchemaIndex_ResolvePath, which is what the Makefile asks for. The
    fixture says so, rather than leaving the omission to be rediscovered.

The executor keeps its refusal for a statement that reaches exec another way;
both now raise the same message through nestedExportMemberError, so the
author sees one wording wherever the statement is stopped.

Tests mirror the grant rule's: fires without a project, ignores an IMPORT
mapping (where collapsing is the supported feature this PR adds), and does
not mistake an object element's `Assoc/Entity` -- a `/` on the other side of
the mapping -- for a nested member.

Verified: `make check-mdl` exits 0, 76 unit packages green, gofmt clean.
Also merges origin/main, which had moved on and left the PR unmergeable.

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

On macOS and Windows the local loop executed whatever Linux binary the
cache held:

  fork/exec ~/.mxcli/mxbuild/11.12.0/modeler/mxbuild: exec format error

MxBuildCDNURL branches on GOARCH and never on GOOS, and both URLs are
Linux tarballs — Mendix ships the macOS mxbuild inside Studio Pro, not on
the CDN. So an arm64 Mac caches a Linux *aarch64* ELF: the architecture
matches, which is why nothing notices until exec.

The rule was already in the codebase twice. `setup mxbuild` asks
NativeMxBuildForSetup, and `docker build` resolves Studio Pro before the
cache — its comment even says "On Windows, CDN downloads are Linux
binaries". The local loop did neither: runlocal.go called DownloadMxBuild
directly and StartServe looked only at the cache.

Three changes:

  * The local path resolves through NativeMxBuildForSetup, so Studio Pro
    wins over the cache on any non-Linux host. Linux is unchanged.
  * LocalRunOptions.MxBuildPath is honoured. It was documented as an
    override and never read for the serve binary, so a user hitting this
    had no way out.
  * A magic-byte check (ELF / Mach-O incl. fat / PE) refuses a foreign
    binary before exec, naming Studio Pro and --mxbuild-path instead of
    "exec format error". An unrecognised format is allowed through: a
    shell wrapper has no magic, and guessing would block a working setup.

The download is deliberately NOT blocked on Windows — the cache holds a
Linux binary there on purpose, for Docker builds. The fix belongs at
resolve and exec time.

The host OS is injected into the helpers rather than read from
runtime.GOOS at the point of use, because this bug is platform-specific
and is otherwise unreachable from a Linux runner. Reverting makes the
explicit-path test fail by spending 34 seconds downloading from the CDN,
which is the ignored override made visible.

Not verified: no macOS host was available, so resolveStudioProDirMacOS
itself is still covered only by its existing tests. The failure mode was
reproduced on Linux by planting a Mach-O at the cache path, which
reproduces the reported message verbatim.
Detection appended the .exe Windows needs; every consumer built the path
by hand and did not. The path passed to mxbuild as --java-exe-path, and
the one exec.Command runs to boot the runtime, were both "<jh>\bin\java"
— so a correctly-detected JDK was handed on in a form that need not
resolve, which from outside reads as "mxcli does not detect Java".

Five sites built that join; they now share JavaExePath. The sixth would
have repeated it.

On the JDK search: "add Studio Pro's JDK" turned out to be a non-task.
Mendix's install guide lists Eclipse Temurin JDK 21 as the prerequisite
and installs it when absent — Studio Pro bundles no JDK of its own, so
the existing Adoptium glob already IS Studio Pro's JDK, and inventing a
Mendix\<version>\jdk path would have been dead code. What was genuinely
missing is the per-user install location (%LOCALAPPDATA%\Programs\…)
that winget and the Temurin MSI can produce, which no Program Files glob
reaches.

The not-found error now lists every location searched, and says which
JDK Mendix itself uses. "JDK 21 not found" alone sent a user reading
mxcli's source to find out what it had looked at.

Gradle needs no change and is worth recording: it ships inside the
mxbuild bundle (modeler/tools/gradle) and mxbuild invokes it. mxcli
never calls gradle, so a "Gradle missing" from a local run points at a
foreign or incomplete mxbuild bundle rather than a missing system
Gradle — the same root as the platform mismatch in mendixlabs#916.

goos is injected into jdkSearchPathsFor and javaExeName because the only
Windows CI job is the tunnel seam, scoped with -run: it compiles Windows
code and executes almost none of it, which is how a missing .exe
survives. Reverting the suffix fails TestJavaExeName.

Not verified: no Windows host was available. This is a code-level fix
with OS-injected tests, and the report came via a user relay rather than
a reproducible case.
The write-stats reporting judged a statement on unit writes alone, which was
wrong for code actions: a JavaScript or Java action's *body* does not live in
its unit — the unit carries the signature, the source lives in
javascriptsource/<module>/actions/<name>.js. Editing only the body elided the
unit write, so the statement reported

    Unchanged javascript action: DT.JS_Ping

while the user's edit had just landed in a file. That is a worse lie than the
one the reporting was introduced to fix, and it was found by measuring the fix
across document types rather than only on the nanoflow from the report.

The generated source file now goes through javaactions.WriteSourceIfChanged,
which reports whether it differed, and both engines fold that into WriteStats.
Skipping an identical file is worth having on its own: an unconditional rewrite
moved the file's mtime on every run, which git does not notice but an
incremental build's caching does.

Also fixes the page path, which printed "Created page X" on the replace path
too, so re-running a script against an unchanged page claimed to create it
every time. It now reports Replaced/Unchanged like every other document type.
Deleting duplicate same-named pages is a real change that unit-write counting
cannot see, so only a one-for-one replacement is eligible for the downgrade.

Measured across document types on one project, changing one thing in each:
enumeration 10/10 element identities kept, microflow 22/22, nanoflow 16/16,
page 65/65, and the domain model 29 of 30 — the single new element being the
attribute's type node, correctly unmatched because Integer and Long are
different $Types. All 8 GUIDs survived, including the retyped attribute's.

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

Two reported bindings that passed `mxcli check`, were written by `exec`, and
did nothing. They share one cause: the allow-lists behind MDL-WIDGET01 and
MDL-WIDGET07 are widget-type AGNOSTIC. isBuiltinPropName is a single flat
list holding both ContentParams and Editable; it answers "is this a real MDL
property name anywhere", and both validators read it as "is this valid on
THIS widget". The engine then acts on each only for the widget kinds that
support it, so the rest is dropped in silence.

Bug 1 -- FIXED. A pluggable widget's text-template property

    imageUrl: '{1}', contentparams: [{1} = PictureUrl]

stored a template with an EMPTY parameter list, and mxbuild answered CE0720
"Place holder index 1 is greater than 0, the number of parameter(s)" on the
FIRST write -- no describe round-trip needed, contrary to the report. A
dynamictext with identical syntax stored the parameter correctly, which is
the control that localised it to the pluggable path.

The engine took the parameters path only for mxcli's `{AttrName}` spelling,
so Mendix's own numeric `{1}` had no route. Both spellings now reach the same
stored shape via SetTextTemplateWithClientParams, added to the builder
interface and to both implementations. Verified: the reported script is 0
errors and the Image's template holds one parameter bound to
Product.PictureUrl, byte-comparable with the dynamictext control.

Bug 2 -- NOT FIXABLE AS ASKED, so reported instead (MDL-WIDGET20). Mendix
models editability on INPUT widgets only: measured against
generated/metamodel, exactly eleven Pages types carry Editability /
ConditionalEditabilitySettings -- ten inputs plus DataView -- and not one of
the fourteen button types does. There is no field to write, so
`editable:` -> Editability on a button cannot be implemented; the issue's own
second option is the right one. The warning names conditional visibility,
which buttons do support.

Both MDL spellings are caught. `editable: 'x'` lowers to `Editable`, the
bracket form `editable: [expr]` to `EditableIf` -- and the bracket form is the
one that genuinely works on inputs, so leaving it unflagged on a button would
have been the more surprising silent drop. The type list is a hand-maintained
bridge between MDL and Mendix names, so a test parses the metamodel and fails
if that set of eleven changes.

Also MDL-WIDGET21, the residue of fixing bug 1: contentparams with no `{N}`
placeholder to consume them still had nothing to attach to and were dropped
without a word.

Neither rule is a .fail.mdl. Both are warnings, so `check` exits 0 and such a
fixture would report "negative test unexpectedly passed" -- the trap the
Makefile documents above check-mdl, and the one that broke mendixlabs#927's CI. They
are demonstrated by a plain .mdl and pinned by unit tests.

Verified: reported script 0 errors under mx check (was CE0720); controls with
each fix removed reproduce CE0720 and drop all three MDL-WIDGET20 cases;
input widgets and 339 shipped examples produce no new warning; `make
check-mdl` exits 0; 76 unit packages green; gofmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
feat(mappings): reach a nested JSON leaf without an entity per level (mendixlabs#927)
A one-line change no longer rewrites every element identity, and a skipped write no longer reports one
fix(run-local): use an mxbuild this host can execute, and say so when none can
Bind a pluggable text template's contentparams, and report editable where Mendix has none (mendixlabs#928)
`import from mapping M.IMM($json)` — no range keyword — built a document the
Mendix runtime refuses:

    MicroflowException: key not found: Path(QName(None,),None,)
      at ...integration.importer.mapping.MappingCache.storeValueMappingElement

The model is valid, so nothing static caught it: mxcli check, mx check (0
errors) and mxbuild all pass. It fails only when the activity runs, and the
repo had no runtime coverage of import mappings — every existing test stopped
at mx check.

The Range and the result variable's cardinality are separate axes (mendixlabs#881), but
an unauthored range set neither pointer, so ForceSingleOccurrence and
ConstantRange.SingleObject both fell back to SingleObject — true for an
object-rooted mapping. That is Studio Pro's First ("take one of a list"), not a
single-object import. Studio Pro writes both flags false and expresses "one
object" solely through VariableType=ObjectType.

`all`, `first` and limit/offset each set the pointers explicitly, so only the
bare form was affected — the form the shipped examples use
(06-rest-client-examples.mdl:1506, :1541). `first` is unchanged.

Measured against a Studio Pro-authored app on 11.13.0: in one boot, over the
same mapping and the same JSON, `... all` imported and the bare form threw;
with the fix both import. The cross-test is what located it — an mxcli
microflow calling Studio Pro's own mapping fails too, which rules out the
mapping document, the JSON structure and the entity.

Also updates TestImportRange_UnauthoredKeepsTheOldInference, which asserted the
faulty fallback. It passed only because it exercised the list-rooted case,
where the fallback is false either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp1syGhH8yr7Hve2wwjzqg
fix(mappings): an unauthored import range is All, not First
…EST02)

`Body: file from $Doc` on a consumed REST operation sent the four bytes `$Doc`
instead of the document. Measured against httpbingo with an 8090-byte PNG:

    Content-Length: 4
    data:application/octet-stream;base64,JERvYw==   ->  b'$Doc'

mxcli check passed, mx check reported 0 errors and the call returned HTTP 200.
Every signal a user or an agent checks said success.

Both engines folded FILE into the TEMPLATE branch and wrote a Rest$StringBody
whose ValueTemplate is the expression text — consumed_rest_write.go:218 (the
default modelsdk engine) and writer_rest.go:250 (legacy). `describe` renders it
back as `Body: template '$Doc'`, so the round trip looked self-consistent.

There is no better type to write. Mendix's 11.13 metamodel has exactly three
request-body types — Rest$JsonBody, Rest$StringBody, Rest$ImplicitMappingBody —
and none is binary, so binary upload is not expressible in MDL at all. The fix
is therefore to refuse the clause, as MDL-REST01 refuses a mapping document in
an inline mapping, rather than degrade it into something that looks like it
works. A Java action is the route.

One function guards both the check pass and exec, so `mxcli check` and
`mxcli exec` cannot disagree — and because it sits in buildRestClientOperation,
`--no-check` does not reopen the silent write either.

The refusal is narrow: `Response: file as $Doc` downloads correctly and is
untouched.

Two shipped examples advertised this as a working "File Upload" feature and
never worked; 06-rest-client-examples.mdl is corrected — one is rewritten as
the download that does work, the other replaced by a note.

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

Binary POST was reported as impossible in MDL — a consumed REST operation has
only Rest$JsonBody, Rest$StringBody and Rest$ImplicitMappingBody, none binary,
so the previous commit refused `Body: file` and pointed at a Java action.

That was the right conclusion about the wrong document. Mendix models a binary
request body on the microflow REST CALL activity:

    "RequestHandling":     {"$Type": "Microflows$BinaryRequestHandling",
                            "Expression": "$FirstFileDoc/Contents"},
    "RequestHandlingType": "Binary"

It is a Microflows$ type, which is why grepping the metamodel for Rest$*Body
finds only the three non-binary ones and appears to prove it impossible. A
Studio Pro-authored example (ako/TestApp, 11.13.0) settled it.

mxcli could PARSE that shape and could neither write, read on the modelsdk
engine, nor describe it. So a Studio Pro binary POST described as a REST call
with no body at all, and re-executing that DESCRIBE produced a request that
sent nothing.

Wired full-stack: grammar (`BODY BINARY expression`), AST, visitor, builder,
both writers, the modelsdk reader and the DESCRIBE formatter. The expression is
the FileDocument's Contents MEMBER and is carried as source text — quoting it
would send the path as a string literal.

RequestHandlingType was hardcoded "Custom" in both engines regardless of the
handler. Only the Binary case is derived; the others are left alone, having no
measured Studio Pro reference and working today.

Verified by re-executing mxcli's DESCRIBE of the Studio Pro microflow and
diffing the BSON: same $Type, same discriminator, same expression, mxbuild 0
errors. `go test ./...` green, `make check-mdl` 354 PASS / 0 FAIL.

MDL-REST02's message, the syntax help, the shipped examples and the symptom
table now point at this route instead of a Java action.

Adds cmd/bsondump, a dev helper that prints a .mxunit as canonical extended
JSON — the technique the symptom table prescribes for this class of bug (plain
bson.M loses key order and hides int32 vs int64).

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

Four defects in the REST call's request handling, all measured against Studio
Pro microflows (ako/TestApp, Mendix 11.13.0) covering Custom, Mapping, FormData
and Binary.

1. The export-mapping body wrote "ParameterVariable". generated/metamodel gives
   MicroflowsMappingRequestHandling exactly three properties — contentType,
   mappingId, mappingVariableName — so mxcli wrote a key the type does not own
   AND omitted the real one. An unknown property is the shape mxbuild tolerates
   and Studio Pro refuses to open. mxcli's own READER had known the correct key
   since mendixlabs#843; the writer was never corrected.

2. ContentType was written empty, which is not a member of the enum (Json|Xml).
   Studio Pro writes "Json".

3. RequestHandlingType was hardcoded "Custom" in both engines regardless of the
   handler, so an export-mapping body claimed to be a custom template. Studio
   Pro pairs Mapping/FormData/Binary/Custom with the matching sub-element; it is
   now derived. (The previous commit derived only Binary, the one case with a
   reference at the time.)

4. FormDataRequestHandling and AdvancedRequestHandling can be parsed and not
   written, so CREATE OR REPLACE/MODIFY dropped the body. Nothing reported it:
   DESCRIBE omits a clause it cannot express, and a REST call with no body
   builds clean, so the app posted nothing and every signal said fine. The
   rewrite is now refused (guard-don't-drop, ADR-0005), mirroring the queued-call
   guard. The allow-list is the writable set, so the guard stops refusing as
   soon as a type becomes expressible — as Binary just did.

Verified by round-tripping Studio Pro's own microflows: DESCRIBE → exec now
reproduces the export-mapping action exactly (type=Mapping, ContentType=Json,
MappingVariableName=NewItem), and the form-data microflow is refused by name
instead of silently emptied.

`go test ./...` green, `make check-mdl` 354 PASS / 0 FAIL.

Not fixed here: the legacy engine encodes MappingId as binary where Studio Pro
stores a qualified-name string. Noted in the symptom table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp1syGhH8yr7Hve2wwjzqg
Re-granting a role on an entity rebuilt the rule from that one statement,
so anything an earlier GRANT had allowed came back None: (READ (Name,
Email)) followed by (READ (Phone)) left a rule reading Phone alone.
Structural rights went the same way -- (CREATE, DELETE, READ *, WRITE *)
followed by a narrow re-grant lost create, delete and every write right,
which the report did not mention. Nothing was said at any point.

The reported trigger was wrong in a way that matters: WHERE is not
required. The same loss reproduces unconstrained and with READ *, so a
fix scoped to the constrained path would have satisfied the repro and
left most of the bug. The legacy engine has merged additively since it
shipped (mergeAccessRule), making this a regression in the codec engine
-- the default -- and explaining why the documented contract ("GRANT is
additive ... never removes permissions") held on one engine only.

A second defect surfaced while measuring the first: both engines matched
a stored rule on its module-role set alone, ignoring XPathConstraint, so
GRANT ... WHERE 'A' followed by WHERE 'B' folded the second onto the
first and overwrote its constraint. Mendix combines the rights of every
rule naming a role ("Rules are additive", refguide/access-rules), so one
rule per constraint is the ordinary way to write row-level security -- a
pattern MDL could not express. The constraint now belongs to the match
key on both engines, with the empty constraint treated as a value rather
than a wildcard, so constrained and unconstrained rules coexist and
re-running a script stays idempotent (ADR-0008).

Consequently formatAccessRuleResult needed the constraint too, or the
Result: line echoed a different rule's rights back at the user; REVOKE
passes anyXPath since it narrows every rule the roles appear in.

Rights merge on None < ReadOnly < ReadWrite, so a merge only ever widens.
Narrowing stays REVOKE's job, which keeps the two commands inverses
rather than two spellings of "set". The lattice is shared via mdl/types
so the engines cannot drift.

Verified: 7 new backend tests (failing first, with the reported
map[Email:None Name:None Phone:ReadOnly]); the reported repro end to end
on both engines; mx check 0 errors on the two-rule form; make check-mdl
green; 76 packages green.

Fixes mendixlabs#936

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
The code changes landed with syntax help, the quick reference and the examples
updated, but not the two SKILLS that `mxcli init` syncs into user projects, nor
the docs site. Those are where an agent or a developer actually looks:

- rest-client.md has a "Body Types" section listing json/template/mapping. It
  now says there is no binary body on a consumed operation, that
  `body: file from $Doc` is refused as MDL-REST02, and where binary POST does
  live. `response: file as $Doc` is called out as unaffected.
- write-microflows.md's REST CALL section gains a binary POST example next to
  the JSON-body one.
- docs-site gains a "POST a Binary Body (File Upload)" section.

The docs-site example is a complete microflow, so it was validated the way the
rest of this work was rather than by eye — and the first version was WRONG:
`RETURNS response` binds a System.HttpResponse, and returning it from a
`RETURNS String` microflow is CE0117 at the end event. `mxcli check` passed it;
mxbuild caught it. The committed version returns Boolean from the status code
and is mxbuild-clean, and the page now states the constraint.

`mdl-examples/bug-tests/rest-binary-post.mdl` was validated the same way (0
errors), since check-mdl only runs `mxcli check` on fixtures.

make check-skill-mdl: 217 blocks checked, all pass.

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

Two defects reported as mendixlabs#935, both verified on Mendix
10.24.20.105674 (the reported version) and 11.13.0.

**1. A dotted column target still corrupted the page (the crash).**

mendixlabs#891 fixed the BARE form — `insert after NextRunAt { … }` — by refusing it
and pointing authors at `grid.NextRunAt`. That form skips the guard
entirely, because the target is legitimate. What is wrong is the pairing:
the executor routes an all-`column` body to InsertColumns/ReplaceColumn,
and anything else fell through to the generic widget path and was
serialized into the grid's COLUMN list. So the fix for mendixlabs#891 named the
route to the same unloadable document:

  System.InvalidCastException: Unable to cast object of type
  '…LayoutWidgets.DivContainers.DivContainer' to type
  '…CustomWidgets.WidgetObject'

mx check aborts on load, before reaching a single check. Refusing at the
pairing covers INSERT (before/after/into) and REPLACE at once. The column
is resolved first, so a mistyped name still reports not-found with the
available names rather than the refusal.

Measured: "Altered page" then a load abort, before. Refused, and the
project still checks at 0 errors, after.

**2. A widget inside a customContent cell bound nothing.**

The entity-context walk descended into a pluggable widget's own widget
properties but not into an object-list ITEM's — a DataGrid2 column keeps
its cell widgets one level deeper, at Objects[].Properties[content].Value
.Widgets. That is the descent findInWidgetChildren gained in mendixlabs#834; this
second walk never learned it, so ALTER PAGE could FIND those widgets (and
mendixlabs#834's fix made that the recommended way to edit a cell) while building
their bindings with an empty entity context.

An association-navigating ContentParams path then could not resolve into
AttributeRef + EntityRef steps and was stored as the attribute NAME:
CE1613 at build time, and a describe that silently dropped the first hop.
CREATE PAGE got the identical page right, which is what made this read as
a storage bug rather than a context bug.

The walk also never read a pluggable widget's datasource at all, so the
grid's own entity was invisible even one level up. Both readers now share
entityFromEntityRef, which also gives the pluggable one the
IndirectEntityRef (association) case the plain one already had.

The descent is keyed on the BSON shape, not on the "columns" property
key, so Accordion groups and PopupMenu items are covered by the same code.

Measured: CE1613 before, 0 errors after, with the two-hop path
round-tripping through describe.

Note on the controls: reverting fix 1 makes its unit test nil-deref in the
fixture rather than reproduce the symptom, so the honest control there is
the CLI plus mx check, run against a real 10.24 project. Fix 2's five
tests do fail with the reported "" before the fix, including a
false-positive control (a nearer DataView must still shadow the grid, and
a widget outside every bound container must still report no entity).

Repros: mdl-examples/bug-tests/935-alter-page-widgets-at-column-target.mdl
and 935-customcontent-column-entity-context.mdl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
fix(rest): request bodies — correct what was silently wrong, add what was missing, refuse what cannot be written
claude and others added 9 commits August 20, 2026 06:24
mendixlabs#923)

DESCRIBE MICROFLOW renders control flow as nested if/then/else, which only
works for properly nested graphs. When a branch re-enters a sibling's path
the describer walks it as a tree and emits MDL that means something else,
silently.

Measured on the reporter's own graph, reconstructed from the coordinates in
their describe output: with their actual expressions the original ALWAYS
logs and the description NEVER does -- the exact inverse, not merely a
semantic difference. The same root cause explains the tangled diagram they
reported separately: findSplitMergePointsForGraph and commonMergeAfter
disagree about which node is split1's merge, so the emitted @merge places it
before an activity that structurally follows it.

Records the negative BSON finding that forecloses the obvious design:
Microflows$ExclusiveMerge has no name, caption or documentation field, per
generated/metamodel, modelsdk/gen, and real 11.13 Studio Pro documents.
Labels therefore cannot be stored -- but they do not need to be, since a
label is an artifact of the emitted text and only has to be deterministic,
not persistent.

Proposes three modes: structured (today, unchanged), a faithful merge/join
label form that also subsumes the @merge annotation, and an opt-in
normalized form that recombines guards. Normalization is bounded by
Bohm-Jacopini -- it works where the extra edges land on a shared suffix, and
is refused where branches genuinely interleave, since that needs either
activity duplication or a variable the user never wrote.

Phase 0 is the detector, shipped as a lint rule so prevalence can be
measured before Modes 1 and 2 are scheduled. It is independently the fix for
the issue: a silent inversion becomes a named refusal.

Refs mendixlabs#923

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
The skill covered the receiving half end to end — JSON structure, entities,
import mapping, REST CALL — and said nothing about sending a body. Adds:

- Step 6 listing the four inline REST CALL body forms (template, expression,
  export mapping, binary)
- an upload example: the expression is the file document's Contents MEMBER, the
  content type goes on a header, and the parameter must be a
  System.FileDocument specialization
- why a consumed REST CLIENT document cannot do this (MDL-REST02), so the
  contrast with the sibling skill is explicit
- two gotcha rows: the CE0117 end-event type error when a `returns response`
  result is returned from a `returns string` microflow, and the 200-with-4-bytes
  upload that MDL-REST02 now refuses

The complete microflow was validated the same way as the docs-site one — exec'd
into a real project and checked with mxbuild (0 errors) — because
check-skill-mdl skips the block and `mxcli check` alone would not have caught
the return-type error that the last documented example shipped with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp1syGhH8yr7Hve2wwjzqg
Two failures in one session came from trusting local state instead of checking
it, and both were reported to the user as fact before being caught.

1. The container is ephemeral and is re-cloned when reprovisioned, so commits
   made earlier in a session can be absent from the working copy while still
   present on the remote. It happened twice. The second time it was misread as a
   code bug: a grammar rule was "missing" from the tree, and several turns went
   into bisecting a parse failure in a binary built from the rolled-back tree.
   The rule had been committed and pushed hours earlier.

2. Commits were pushed onto a branch whose pull request had already merged, and
   described as "added to PR #N" when #N was closed and contained none of them.
   A merged PR cannot take new commits.

SessionStart now reports, and stays silent otherwise:
  - HEAD behind origin/<branch> — the stale-checkout signal
  - bin/mxcli built from a different commit than HEAD (the Makefile already
    stamps it via -X main.Version), so behaviour observed through the binary is
    known to come from the code under test

PostToolUse on Bash runs after any command containing "git push" and reports a
branch that is behind origin/main, which is the state both PR failures shared —
main had absorbed the branch's earlier commits via the merge. It distinguishes
"behind with commits of its own" (a merged PR cannot carry them) from "behind
with none" (stale checkout or merged PR) and prints the recovery command for
each. Nothing is printed for the normal ahead-of-main push.

Naming the PR directly would be better, but this environment's egress proxy
intercepts api.github.com and answers 403 ("GitHub access is not enabled for
this session"), so a shell hook cannot ask. What it can compute locally is
enough to stop the wrong claim being made.

Matched on Bash rather than the hook's `if` filter: that is a PREFIX match and
would miss `git add … && git commit … && git push …`, which is how most of these
pushes are actually run. The script exits immediately on a non-push command.

Both hooks output through jq as JSON so the message reaches the user
(systemMessage) and the model's context (additionalContext) — the latter is
where the incorrect claim was made.

Verified by running session-start.sh directly and by firing the PostToolUse hook
with a temporary sentinel. The branch check earned its place immediately: it
found that d867785 (this branch's upload-section commit) was never in PR #193,
which merged at c6bd29c, and that restarting the branch from main had moved off
it.

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

DESCRIBE MICROFLOW renders control flow as nested IF/THEN/ELSE, which only
works when the graph is properly nested. A Mendix microflow is an arbitrary
graph, and when a branch re-enters a sibling branch's path there is no
nesting that means the same thing -- the describer emitted one anyway, with
no error and no warning.

Measured on the reporter's graph, reconstructed from the coordinates in
their own describe output: the log activity ran on NOT c1 OR c2 and was
described as c1 AND c2. With their actual expressions (not(true) on both
decisions) the original ALWAYS logs and the description NEVER does -- the
exact inverse, so re-executing it produced the opposite program. The
separate complaint about the diagram coming back tangled is the same cause:
findSplitMergePointsForGraph and commonMergeAfter are two independent
merge-finders that agree on every nested graph and disagree here, so the
emitted @merge lands before an activity that structurally follows it.

Phase 0 of PROPOSAL_structured_microflow_description.md: detection only.

- mdl/microflowgraph: post-dominance plus branch-body overlap, classifying
  an overlap with one entry as recombinable (the guards fold) and two or
  more as interleaved (needs activity duplication or a synthetic boolean per
  Bohm-Jacopini, so it is refused rather than rewritten). Deliberately does
  NOT reuse the describer's join search: mxcli has two and they disagree on
  exactly these graphs, so a detector built on either inherits whichever is
  wrong. It also cannot live in mdl/executor, which imports mdl/linter.
- MDL-FLOW01 reports them, with different advice per class.
- DESCRIBE emits a -- WARNING: comment naming the decision's position and
  refusing the round trip.

Not registered in mxcli report: that score grades the model, and the model
is valid -- what fails is mxcli's ability to describe it.

The false positive would be worse than the bug, so the negative controls
pin the shapes most likely to trip it: if with no else, an inner split whose
join is the outer's, branches that both return, retry-loop back edges, and
error-handler flows. Wiring proved with a forced-fire control against a real
project, since a rule that never runs and a rule that finds nothing look
identical. DgDemo reports the same 25 issues as before.

Refs mendixlabs#923

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
fix(security): GRANT widens an access rule instead of replacing it
fix(alter-page): refuse widgets at a DataGrid2 column target, see into a customContent cell
chore(hooks): catch a stale checkout and a merged-PR push, plus the REST upload docs #193 missed
@ako
ako merged commit 766cdf6 into mendixlabs:main Aug 20, 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