Skip to content

Sync ako/mxcli: agent loop efficiency, page/widget writes, workflow fixes - #1163

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

ako merged 56 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

38 commits since the last sync. The bulk is one investigation — why an agent-driven
mxcli session costs several times what the same app costs on Vercel — plus the
fixes it turned up. The rest is pages/widgets, workflows and a set of corrections
to documentation that was measurably wrong.

Agent loop efficiency

A side-by-side build of the same class of app put mxcli at 4.25x the model calls
and 5.4x the conversation re-read
(523 vs 123 calls; 228M vs 42M cache-read
tokens). Every model call re-reads the whole transcript, so the total is calls x
conversation size — which means removing a call removes its tool output from every
later call too, and the cost falls with the square of the call count. Output size
is not the lever: 500k of 228M.

Three defects the loop-report work found once it ran against a real project:

  • It counted a minority of runs and presented it as all (diag loop-report counts only the minority of commands that happen to log, and presents the total as if it were all invocations ako/mxcli#617).
    newLoggedExecutor sat inside check's if checkRefs block, so a check
    without -p never logged; docker check, run, test and marketplace wrote
    nothing either. The same window went from 301 to 442 invocations, with
    check showing 10 unclosed where it had shown 0. The first fix broke the signal
    in the other direction — a clean run of a non-logging command became a false
    failure — so closing moved to PersistentPostRun, which cobra skips on
    os.Exit, keeping "no session_end" as the tell.
  • A file that parsed to nothing passed both gates (A file of zero recognised statements passes check and is silently applied by exec ako/mxcli#618). visitor.Build
    returns zero statements and zero errors for text the grammar cannot begin to
    parse, so it is indistinguishable from an empty file: check said "Check
    passed!" and exec applied nothing, both at exit 0. A truncated file or a lost
    heredoc therefore replayed as success while the model did not change. Both gates
    now refuse it and name the first line that did not parse; empty, whitespace-only
    and comments-only files stay passes, with a test each.
  • The failed field never counted a failure (diag loop-report: the failed field never counts a failure ako/mxcli#620). It read 0 across
    a 442-invocation log while runs were exiting non-zero, because it counts runs
    that closed while reporting statement-level errors — reachable only via
    exec --continue-on-error — and a real failure exits through os.Exit and lands
    in unclosed. Renamed to runs_with_statement_errors and printed only when
    non-zero; a 0 beside a non-zero unclosed count was the misreading itself.

That last guard then went red in CI, and it was right to: 15-fragment-examples.test.mdl
declares no @test block, so it rendered to nothing and make check-mdl had been
reporting PASS over 416 lines nothing ever read — concealing a real MDL-PAGE20
violation. Two fixes: check now diagnoses a test file on the text the parser was
actually given (it had been quoting a source line the parser never saw), and the
fixture is renamed to .mdl and genuinely checked.

Pages, widgets and layout

  • ALTER PAGES … WHERE WIDGETTYPE bulk-sets design properties across pages.
  • ALTER PAGE … SET writes Atlas design properties.
  • A layout rewrite updates the stored unit instead of replacing it, so element
    identities survive. Regression case included, with the delete+insert tell.
  • UPDATE WIDGETS no longer reports success after writing nothing.
  • A primitive snippet parameter is refused rather than silently resolved as an
    entity.

Workflows

Corrections backed by measurement

CI

  • go test output is streamed rather than captured (Bind a pluggable widget's textTemplate to an attribute (#575) ako/mxcli#594), so a
    failure is visible while the job runs.
  • The Windows grandchild marker named a process that did not exist yet
    cmd.Start() guarantees only that cmd.exe was created, not ping.exe, so
    taskkill /F /T enumerated a tree the grandchild had not joined and
    cmd.Wait() hung. It read as Windows flakiness and was attributed to whichever
    PR happened to be red. The marker is now emitted by the process under test, over
    the channel under test, matching the unix half's handshake

claude and others added 30 commits September 20, 2026 13:10
…ing it as an entity

`create snippet Test.SNIPPET_Label (params: { $Label: string })` — the spelling
`mxcli syntax snippet.create` printed in its own Syntax line — passed
`mxcli check` and failed at exec with

  failed to build snippet: failed to resolve entity string: entity not found: string

naming a type nobody spelled (mendixlabs#1028).

`snippetParameter`/`snippetParameterList` in MDLPage.g4 were a byte-identical
duplicate of `pageParameter`/`pageParameterList` with their own visitor, which
never called buildDataType — so a primitive type never reached the AST, and
buildSnippetV3 (with no primitive branch either) took the source text for an
entity name. The same duplication had produced the quoted-name bug fixed just
before this one, and patching the copy that time left this half live and made it
silent. The duplicate rule and its visitor are gone: a snippet's Params clause
IS pageParameterList.

The obvious repair is the wrong one. Storage would take a primitive —
Forms$SnippetParameter's ParameterType is the polymorphic DataTypes$DataType,
exactly as Forms$PageParameter's is — but mxbuild will not. Measured on 11.13.0
in one run, against a project holding nothing else:

  snippet params { $Label: string }        -> CE0046 "Invalid data type 'String'."
  snippet params, all six primitives       -> one CE0046 each
  snippet params { $Order: Mod.Order }     -> 0 errors
  PAGE params, the same six primitives     -> 0 errors

So the restriction is on snippet parameters, not on primitives, and a primitive
one is refused rather than written — by one rule (types.SnippetParameterTypeRule)
that `mxcli check` reports as MDL087 and buildSnippetV3 refuses with, so a script
cannot pass one and fail the other. Both name CE0046 and the caption mxbuild
quotes, so the message matches a build log verbatim.

Also fixed, found on the way:

- pageParamBSONType returned "DataTypes$LongType", a $Type that exists in
  neither generated/metamodel nor modelsdk/gen (constant_write.go has carried
  the note, "storage has no LongType"). pageParamTypeToGen's default arm quietly
  rescued it into a String, so a `Long` PAGE parameter had been stored as String.
  It is now IntegerType, which is Studio Pro's single "Integer/Long" — verified
  at 0 errors on 11.13.0 and round-tripping through `describe` as Integer.
- describe of a snippet parameter went through extractEntityQualifiedName, which
  answers "Unknown" for anything that is not an entity — MDL that re-executes as
  a reference to an entity called Unknown.
- pageParamTypeToGen and snippetParameterToGen were two copies of one conversion;
  the snippet's could only ever produce an ObjectType. Now one builder.
- `mxcli syntax snippet.create`, docs-site and the overview-pages skill said a
  snippet parameter may be a primitive. The reporter reached the bug by
  following that line.

Control (recorded in the tests): with the pre-fix visitor restored, the new
executor test fails with the reported message verbatim, and with
snippetParameterToGen's unconditional ObjectType restored the write test reports
`{"$Type": "DataTypes$ObjectType", "Entity": ""}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McnNijpTzHtdEf6mtmz53r
`ON CREATED MICROFLOW` written in the wrong position failed to parse with
`mismatched input 'ON' expecting ';'`, which names neither the clause nor
the rule — and cascaded into up to six more errors, including a bogus
`extraneous input 'END'`. The reporter worked the required order out
empirically and wrote it into their notes.

`createWorkflowStatement` and `workflowUserTaskStmt` were a fixed sequence
of optional groups, so every clause was optional but its position was not.
They are now a set: any order, each at most once, which is how the rest of
MDL reads (ADR-0003).

Relaxing the grammar alone would have mis-assigned every clause, because
the visitor read qualified names by counting them (`names[1]` or `names[2]`
for the overview page depending on whether PARAMETER was present) and
strings by index. Each clause is now read off its own clause context.

Two things the clause set had to keep from the sequence:

- At most once. `page M.A page M.B` would otherwise parse with the second
  silently winning — a worse failure than the parse error it replaces. The
  rule is enforced in the visitor rather than the grammar, so the message
  can name the clause: `duplicate PAGE clause on user task Review (already
  given on line 12)`. The list-valued clauses — `outcomes`, `boundary
  event`, and the header's event handlers — still accumulate.
- The multi-user vocabulary. The MULTI alternative keeps its own clause
  rule, so a single user task still refuses `participants`, `decide by`
  and `await all users`.

`targeting microflow` and `targeting xpath` now count as one clause. A
user task stores one UserSource, and the sequence grammar accepted both
and let whichever was written last silently win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qPaSqkSeaM4Ziuex4nxSG
Two jobs captured their test command into a variable and echoed it on the
next line:

  out=$(go test -v -count=1 -run '...' ./cmd/mxcli/docker/)
  echo "$out"

The runner's shell is `bash --noprofile --norc -e -o pipefail`, so a
non-zero `go test` aborts the step AT THE ASSIGNMENT and the echo never
runs. The capture prints on success, where nobody reads it, and prints
nothing on failure, where it is the only thing anyone wants.

Measured on #594: `windows-process-regression` went red with a
log holding one line of substance --

  ##[error]Process completed with exit code 1

-- no test name, no failure message. The only evidence of WHICH test had
tripped was the runner's own cleanup line, `Terminate orphan process: pid
(2760) (PING)`, which the green run on main does not have: the `ping`
grandchild that TestKillProcessGroup_ReapsGrandchildAndUnblocksWait
spawns, still alive because the test had hit one of its deadlines.

Replaced with `2>&1 | tee go-test-output.txt`, `status=${PIPESTATUS[0]}`,
then grep the file. Output streams as it is produced, the vacuous-`-run`
guard the capture existed for still counts its `--- PASS:` lines, and a
real failure exits with go test's own status.

Verified without CI, by extracting each step's `run:` block straight out
of the YAML (yaml.safe_load) and running it under the runner's own shell
flags with a stub `go` on PATH. Six cases, all correct:

  win  job  fail    -> full output + "go test exited 1"      exit 1
  win  job  pass    -> full output + "executed: 9"           exit 0
  win  job  vacuous -> "executed: 0" + the renamed-tests msg exit 1
  seam job  fail    -> full output + "go test exited 1"      exit 1
  seam job  pass    -> full output + "executed: 4"           exit 0
  seam job  vacuous -> "executed: 0" + the !linux-stub msg   exit 1

Control: the OLD body, same stub, prints zero lines and exits 1 -- the
CI log reproduced exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXYNJwiutu5AjxLmG7Fgsu
`create workflow … overview page X` reported `Created workflow` and exit 0
and stored nothing. The written unit carried no page reference and not even
the page's qualified name as a string; `mx check` passed, because a workflow
with no overview page is valid, and `describe workflow` omitted the clause,
so nothing revealed the loss.

Two fields for one concept, never joined. The executor set the semantic
`Workflow.OverviewPage`; the backend only ever wrote `Workflow.AdminPage`,
which nothing set. The read half was wrong in the mirror direction — the
reader took `g.OverviewPageQualifiedName()` — so even `alter workflow … set
overview page`, which writes the right key and always has, read back empty.

Which name is the stored one is settled by the Model SDK's own
StructureVersionInfo (mendixmodelsdk 4.115.0, src/gen/workflows.js):

    overviewPage: { deleted:    "9.11.0" }
    adminPage:    { introduced: "9.11.0" }

and generated/metamodel, the arbiter where it and modelsdk/gen disagree,
declares AdminPage and no OverviewPage at all. modelsdk/gen declares both,
which is how a reader and a writer in one package ended up on opposite
sides of a 9.11 rename.

The two semantic fields collapse into one. OverviewPage is the word the MDL
clause, the describer and the catalog use; the storage name stays in the
storage adapter, per ADR-0005.

Only AdminPage is written. The version branch CLAUDE.md's overlay rule asks
for on a CREATE would be dead code here, and that is a measurement rather
than an assumption: workflowToGen writes WorkflowV2 unconditionally, and
that property was introduced in 11.1.0, so no reachable project wants the
pre-9.11 key. It is still read, as a fallback — a read fallback invents
nothing.

Measured on mxbuild 11.6.6, same script and project, only the write
suppressed:

                                      control        fixed
  workflow unit                    2,192 bytes  2,284 bytes
  page in the document                      no          yes
  describe workflow                  no clause  overview page WF586B.Overview
  mx check, valid overview page       0 errors     0 errors
  mx check, page without a param      0 errors       CE7410

The last row is the evidence. mxbuild can only validate a page it can see,
so CE7410 firing only with the fix proves the property reaches the platform;
the row above it is why the unit test asserts on the raw document instead of
on a build.

That rule is now documented too: an overview page takes System.Workflow,
while a user task's page takes System.WorkflowUserTask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qPaSqkSeaM4Ziuex4nxSG
Both skills offered a microflow data source as the way past the System-module
ceiling. It is not one, and the way it fails is silent: a workflow inbox built
that way drew the right number of cards and every card was blank, with mxcli
check, lint, report and docker check all at 0 errors.

The rule, measured in a browser on 11.14.0 rather than reasoned about: a
microflow data source moves the ROWS, not the MEMBERS. A microflow does not
apply entity access, so its retrieve returns every row — but the runtime
re-applies entity access when it serializes those objects to the client, XPath
constraint included, so a row the role may not read arrives with every member
empty.

The probe is one page with TWO microflow-sourced lists over the SAME System.User
retrieve, opened by an Administrator and by a plain User:

  list                                        Administrator          plain User
  A  the System.User objects                  admin, viewer          (blank), viewer
  B  a module-owned copy, Name read           admin, viewer          admin, viewer
     INSIDE the microflow

All four cells hold two rows, which is what proves the microflow did carry the
rows past entity access and isolates the loss to serialization. Only A loses the
values, and it loses them per OBJECT: System.User's rule grants read where
[id = '[%CurrentUser%]'], which is why a user picker shows you yourself and
nobody else while a workflow inbox — no such escape hatch for an ordinary role —
comes out entirely blank.

The control is the ROLE, not a before/after build: same binary, same model, two
logins.

- manage-security gains the rule, the measurement and a second remedy (read the
  member inside the microflow, return an object your module owns), replacing the
  advice that caused the rebuild.
- system-module states the rule where it repeats the ceiling, rather than linking
  to it.
- mdl-examples/bug-tests/security-587-system-member-access.mdl is the probe, so
  the measurement can be repeated rather than taken on trust. Verified from a
  blank project: mxcli check --references clean, exec clean, mx check 0 errors.

One thing found on the way, not fixed here: `grant <Role> on System.User` PASSES
`mxcli check --references` and is refused only by `exec`, so a script carrying
one checks clean and then stops part-way. Noted in the skill and in the finding.

Closes #587

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2aYb3zDscezm874CDTH6R
fix: workflow clause order, and the dropped overview page (#586)
ci: stream go test output instead of capturing it
fix(snippets): refuse a primitive snippet parameter instead of resolving it as an entity
Correct the System-module ceiling advice with a measurement (#587)
modelsdk/mpr/version declared its own ProjectVersion struct with the same seven
fields as mdl/types.ProjectVersion rather than aliasing it, so the two were
unrelated Go types that print under the same name: a value could not cross the
mdl/ <-> modelsdk/ boundary without a conversion. The sdk/mpr copy deleted in
the legacy-engine retirement aliased the canonical type; this one did not, and
CLAUDE.md's shared-types rule asks for the alias.

A same-shape duplicate is invisible to every signal except an assignment across
the boundary. It compiles, the tests pass, and the error it eventually produces
names the same type on both sides of "want" — the shape that cost a session once
already on widget BSON, where a delegation handed back a v2 bson.D to a caller
asserting the v1 one and the failure read "widget type is bson.D, want bson.D".

So the guard is a COMPILE-TIME assertion rather than a test body:

    var _ *types.ProjectVersion = (*version.ProjectVersion)(nil)

which builds only under an alias. Written first, it failed to compile with three
errors showing the two types non-interchangeable in both directions — that
failure is the reproduction.

Two measurements made the cleanup safe rather than brave. The four methods this
package redeclared (IsAtLeast, IsAtLeastFull, String, IsMPRv2) were diffed
against types' BEFORE assuming they were redundant: identical behaviour,
IsAtLeast differing only in early-return style. And the two that could not
survive as methods on an aliased type, IsSupported and SupportsFeature, were
counted first — zero callers anywhere, in this package or any other — so they go
with Feature, MinVersion, featureVersions and SupportedVersionRange. That map
described itself as "the fallback when the YAML registry is unavailable"; the
live registry is sdk/versions/mendix-{9,10,11}.yaml read through checkFeature,
so this was a second hand-maintained copy with nothing reading it.

CLAUDE.md cited this as an open cautionary case; it now states the rule and
points at the compile-time guard.

Gates: build, vet (incl. -tags integration), go test ./... (exit 0), check-mdl
(611), check-findings (1,164). Four cmd/mxcli skill-reading tests failed once in
a full run interleaved with make check-mdl, which runs sync-skills
(rsync --delete into cmd/mxcli/skills/); they pass in isolation, on clean main,
and in an uninterleaved full run, so they are not this change.

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

`update widgets` counted widgets it FOUND, not assignments that SUCCEEDED, so
a run where everything was refused still claimed success --- immediately
after warning about each refusal. Measured on a blank 11.12.2 project with
two Data grid 2 widgets:

    Found 2 widget(s) in 2 container(s) matching the criteria
      Warning: Failed to set 'Compact' on dgA: pluggable property … not found
      Warning: Failed to set 'Striped' on dgA: pluggable property … not found
      …same for dgB…
    Updated 2 widget(s)
    Note: Run 'refresh catalog full force' to update the catalog with changes.

Four failures out of four, an instruction to pick up changes that did not
exist, and exit 0.

`updated++` sat outside the assignment loop and was unconditional, so the
counter meant "this widget was found". The same counter gated
`mutator.Save()`, so a container whose every assignment failed was saved
anyway.

The outcome is now the three things that actually happen --- changed,
matched but unwritable, listed in the catalog but absent from the document
--- because rounding the third into either of the others is how a stale
catalog reads as success. Save is gated on something having changed, the
catalog note only prints when it is true, and a run that matched widgets and
wrote none of them exits non-zero with a message naming the likely cause.

    Updated 0 widget(s)
    2 widget(s) matched but had no property that could be set
    Error: no widget was updated: 4 assignment(s) could not be applied. A
    design property (Atlas styling) is not a pluggable widget property and
    cannot be set this way — see `mxcli syntax page.styling`
    exit 1

DRY RUN had the same defect one step earlier, and is the worse half because
the syntax help says to run it first: it printed `Would set …` without
attempting anything. It now applies the assignments to a discardable copy
(`pagemutator.Probe`, the seam `mxcli check` already uses for ALTER PAGE SET)
and reports `Cannot set` / `Would update 0`. Best-effort: a mutator without a
probe keeps the optimistic preview, which is no worse than before.

Severity was bounded, and the commit says so rather than letting this read as
corruption: the rebuilt document was semantically identical, so ADR-0008
elision skipped the write --- no mprcontents/ unit changed mtime, `mx check`
stayed at 0 errors. A reporting defect, not a data one.

Controls, all passing: a real pluggable property still counts, saves, and
round-trips (`PageSize: 25` via `describe page`, `mx check` 0 errors); a
partial run reports both halves; a widget missing from the document is
neither changed nor a property failure.

Not fixed here: `Compact`/`Striped` are Atlas DESIGN properties in
Appearance.DesignProperties, which SetWidgetProperty does not reach. That
capability gap is #515.

Fixes #520

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYaTtzjmMCUjo8XAVN1sQx
`CREATE OR REPLACE LAYOUT` was DeleteLayout + CreateLayout with a freshly built
layout, and a create goes through InsertUnit under a newly minted id. So an
identical re-run replaced the layout's unit under a new GUID every time.
Measured on a blank 11.14.0 project, three runs of one idempotent statement:

  run 1  Replaced layout …  D mprcontents/8a/a3/8aa37ee1-….mxunit  ?? .../fd/6e/
  run 2  Replaced layout …  D mprcontents/fd/6e/fd6e9c96-….mxunit  ?? .../6d/2a/
  run 3  Replaced layout …  3 changed files

The whole .mxunit is renamed each run — a delete plus an untracked add, not
churned bytes in a stable file — so `git status` never comes back clean and an
MDL-generated project is not reviewable in version control.

The storage-layer net for delete+insert recreates cannot reach this. It keys on
the unit ID and this path re-mints it, so there is nothing to reconcile the
re-insert against; the decision has to be made at the statement.

execCreateLayout now keeps the stored layout's unit and rewrites it through a
new UpdateLayout, which goes to UpdateRawUnit and so reaches canon.Reconcile: an
identical rewrite is elided outright and a real one keeps the stored element
$IDs. Duplicates of the same name are still deleted; a layout that does not
exist yet still goes through the insert. The verb is reported through
ReportMutation, so an elided rewrite says Unchanged rather than claiming a
replacement that did not happen.

Measured after: three identical re-runs report `Unchanged layout …` with 0
changed files, and a real edit reports `Replaced layout …` as a modification to
the SAME .mxunit file. 0 errors on mxbuild 11.14.0 with a page bound to it.

A second defect falls out, and only the control found it: a layout MOVEd into a
folder was filed back into the module root on every rewrite. There is no FOLDER
clause on CREATE LAYOUT, so the rebuild always names the module root and the
insert applied it to the unit's row — the defect mendixlabs#932 fixed for REST clients.
Measured, `show layouts` Folder column: Layouts -> (empty) on the old build,
Layouts -> Layouts on this one. An in-place write does not touch the row.

Neither symptom is visible to a build. With a real page bound to the churned
layout, mxbuild reports 0 errors on both variants, because pages resolve layouts
by qualified name and not by unit GUID — which also corrects #556's
reading of this as the same class as #553, where the project stopped loading.

Refs: #600, #556

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWZjWZQhk3cNQCcy1Z2xzH
The bug test is idempotent: running it twice must leave the project
byte-identical. Control on a binary built without the fix — `Replaced layout …`
and 3 changed files on every re-run, against `Unchanged layout …` and a clean
tree with it.

CLAUDE.md gains the rule the third instance in a week earned. The #556 carry
keys on the unit ID, so it does not reach a handler that re-mints one, and three
did: REST client (#556), view entity OQL document (#583), layout (#600). The
cheap tell is to `ls` the .mxunit filenames across two identical runs — a
changed filename is delete+insert and the handler is wrong, a same filename with
different bytes is the codec or a missing carry and canon is where to look.

Refs: #600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWZjWZQhk3cNQCcy1Z2xzH
`alter page … set '<design property>' = <value> on <widget>` dead-ended:
`set` reaches a handful of first-class properties and the stored widget's
pluggable property bag, while a design property lives in
Appearance.DesignProperties. The only spelling that worked was ALTER
STYLING --- a second statement for the same operation.

The same operation, measured rather than assumed. ALTER STYLING's grammar
is

    ALTER STYLING ON (PAGE|SNIPPET) qualifiedName WIDGET IDENTIFIER …

with BOTH operands mandatory (omitting either is a parse error), and there
is no bulk form. So it is the one-widget-on-one-page operation ALTER PAGE
SET already performs, which "One Way to Do Each Thing" rules out having
twice.

    alter page MyFirstModule.ThingList {
      set 'Row size' = 'Small' on lvThings;
      set 'Hover style' = on on lvThings;
    };

Measured on a blank 11.12.2 project: describe styling reads back
`['Row size': 'Small', 'Hover style': on]`, mx check 0 errors --- and
writing the same thing via ALTER STYLING first makes this run rewrite ZERO
units, so elision found the two documents semantically equal. (`Altered
page` is ALTER PAGE's fixed verb, not the elision verb, so it proves
nothing on its own.)

The resolver this needed already existed with no callers.
bsonTypeToDesignPropsKey maps $Type → theme key and had never been
referenced, hence never validated; #509 deliberately avoided
standing up a third consumer of the concept before something needed it, and
this is that something. The mutator returns raw storage facts
(WidgetStorageType: $Type plus, for a pluggable widget, Type.WidgetId) and
the executor owns the mapping, so the theme concern stays in one place.

Routing happens only on a POSITIVE declaration for this widget's type.
Anything else falls through to the pluggable setter and keeps that error ---
routing on "the theme says nothing, so it must be a design property" is how
a typo becomes a silently-written design property.

Two shapes are refused, inheriting existing refusals rather than forking the
vocabulary: a flat value on a multi-select property (CE6084,
#511) and a compound one. Both name the inline
`DesignProperties: [...]` spelling, because a SET assignment carries one
scalar --- exactly as a StylingAssignment does.

TestBsonTypeAndKeywordDesignPropsKeysAgree pins both maps. It does NOT
assert they agree, because they do not, and both directions have measured
reasons: $Type-only DataGrid/Gallery are the native widgets whose MDL
keywords now resolve to pluggable ids, so the stored path resolves more than
the inline one; keyword-only Header/Footer are wrong, since MDL builds both
as Forms$DivContainer and Atlas declares no such groups --- so inline
design-property validation for a header silently skips the widget. Left
uncorrected: it changes what existing pages validate against.

Knock-on: the mendixlabs#1135 error message named ALTER STYLING as the route, which is
stale now that `set` has it. It now says the key is neither a property of
the widget nor a design property the theme declares for it, and points at
`show design properties`. The test asserting the old wording is inverted.

Part of #515; the plural ALTER PAGES form is still to come.

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

The four reference pages that document the .mpr format described a
`UnitContents` table holding v1's BSON blobs, and a v1/v2 detection recipe
that probes for it. No .mpr has ever had that table. A v1 file has exactly
two tables — `Unit` and `_MetaData` — and document contents are the
`Unit.Contents` blob; `grep -rn UnitContents --include=*.go` is 0 hits.

The detection recipe was the worse half: implemented as written the probe can
never succeed, so it returns v2 for every project including genuine v1 ones —
a wrong answer rather than an error. What the code actually does is check for
the `mprcontents/` directory, falling back to whether `Unit` has a `Contents`
column for a .mpr copied away from its folder.

Two further fabrications on the same pages, not in the report: `Unit` was
given `UnitType` and `Name` columns (it has seven, and neither is among them —
type and name come out of the BSON `$Type`/`Name`), and `mprcontents/` was
drawn flat when it is sharded `<XX>/<YY>/<swapped-uuid>.mxunit`.

Rewritten from the SQLite catalogs of two real fixtures: the v1 project in
modelsdk/mpr/testdata (Mendix 9.24.30) and the v2 project in
testdata/expr-checker (11.6.6).

modelsdk/mpr/docs_schema_test.go holds the pages there. Prose cannot be
type-checked but the identifiers in it can: the rule checks only names
beginning with a real table name against the union of the fixtures' tables
and columns, so `UnitContents` and `UnitType` are caught while the catalog
tables these same pages mention (`REFS` and friends) need no allowlist. The
page set is discovered by content, so a page added later is covered.

One consequence is stated in the test: a page that wants to say a column does
NOT exist must say it in prose. Code font there is indistinguishable from the
defect — the old pages' "no `UnitContents`" read as a v1/v2 difference rather
than as a fiction, and an exemption for denials would have masked it.

Control: with the doc edits stashed and the test kept, the failures reproduce
the reporter's line list verbatim — version-compatibility.md:31,
mpr-format.md:21,23, mpr-v1-v2.md:12,35,69,73,74,84,94,
10-bson-mapping.md:30 — plus the two they had not found.

No bug-test MDL: the defect is in prose, and no MDL statement reproduces it.

Refs mendixlabs#1072

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kg5TN4Brse6DJ9WHbm3Ud
The "Unit Types" tables on the two MPR reference pages mapped a document's
BSON `$Type` to a document kind, and 15 rows named a spelling no unit
carries. Four gave the TypeScript SDK's qualified name instead of the storage
name — `Pages$Page`, `Pages$Layout`, `Pages$Snippet`, `Pages$BuildingBlock`,
where every real unit says `Forms$*` — and 10-bson-mapping.md lowercased
eleven more (`microflows$microflow`, `pages$page`, `security$ProjectSecurity`
and friends), which matters because `$Type` is case-sensitive. Selecting on
either spelling matches zero units: a wrong answer rather than an error, the
same failure mode as the UnitContents detection recipe in the previous commit.

`CustomWidgets$customwidget` was removed rather than corrected. It is a widget
element inside a page's widget tree (`CustomWidgets$CustomWidget`), never a
unit.

Both tables are now the measured set: every distinct `$Type` across a blank
Mendix 11.6.6 app (369 units) and a 9.24.30 app (20 units), 28 in all. Types a
blank project has no instance of are listed separately and sourced to mxcli's
own readers and writers rather than presented as measured.

TestDocumentedUnitTypesUseStorageNames holds them there. It keys on the local
name after the `$`, case-insensitively: a fixture cannot prove a type absent —
a blank project simply has no business-event service — so requiring every
documented type to be present would fail rows that are correct. When a fixture
does have a type with the same local name, the documented row must equal it
exactly. That catches all four `Pages$` rows and all eleven lowercase ones
with no false positives on legitimately absent types.

The limit is stated in the test: a row whose local name appears nowhere in the
fixtures is not checked, which is how `CustomWidgets$customwidget` slipped past
and had to be removed by hand.

Control: with the two tables reverted and the test kept, it fails on exactly
those 15 rows and no others.

Refs mendixlabs#1072

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

The singular form (previous commit) styles one widget on one page. Applying
a styling decision across an app — "every data grid gets Compact and Striped"
— meant one statement per widget per page, which is how a design system drifts.

    ALTER PAGES [IN <module>]
      SET '<design property>' = '<value>' | ON | OFF [, ...]
      WHERE WIDGETTYPE = <keyword|'widget id'>
      [DRY RUN]

The selector resolves an MDL widget keyword (datagrid, combobox, ...) to the
pluggable widget id, so the statement is written in the same vocabulary the
rest of MDL uses; a raw widget id is still accepted for a widget MDL has no
keyword for. Matching reuses findMatchingWidgets from UPDATE WIDGETS, so
pages and snippets are both covered.

Reporting reuses the updateOutcome type from the UPDATE WIDGETS fix in
79f49c8, for the same reason: a bulk statement that matched widgets but
could not set the property on any of them exits non-zero and says so, rather
than printing a success line that ADR-0008 elision then quietly makes untrue.
DRY RUN goes through pagemutator.Probe(), so the preview is the real setter
on a discarded copy rather than a guess.

Verified end to end on an 11.12.2 project: dry run names both grids, apply
reports 2 styled, `describe styling` shows both properties, `mx check` is
0 errors, and a misspelled property key reports 0 styled / 2 matched with
nothing settable and exits 1.

Refs #515

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

windows-process-regression failed intermittently on
TestKillProcessGroup_ReapsGrandchildAndUnblocksWait — "cmd.Wait() did not
return after killProcessGroup", ~20.5s at the select deadline, with the
runner afterwards reporting `Terminate orphan process: pid (5264) (PING)`.
killProcessGroup itself reported no error.

The readiness marker named the wrong process. The `spawn` helper started
`cmd /c ping -n 60 127.0.0.1` and wrote "grandchild-started" immediately
after gc.Start() returned. Start() only guarantees cmd.exe was CREATED;
ping.exe — the process that ends up holding the inherited stdout pipe — does
not exist yet. The test raced ahead to killProcessGroup, `taskkill /F /T`
enumerated a tree ping.exe had not joined, returned 0, and ping survived
holding the write end, so cmd.Wait() never saw EOF.

The failing job's timing is the evidence: the 15s marker wait did not fire.
Of the 20.56s total, 0.56s elapsed before the kill — the marker had arrived
promptly and the kill went ahead regardless.

The grandchild now announces ITSELF, with its own pid, over the inherited
pipe: a third helper mode re-execing the test binary instead of
`cmd /c ping`. Reading the marker therefore proves the pipe holder is
running, and the test asserts processAlive on that pid before killing — a
control the test did not have, so a green run now means a tree kill was
really exercised rather than a race that killed a one-process tree.

This is the unix half's handshake, which the Windows half had silently
diverged from: procgroup_unix_test.go runs `sh -c 'sleep 60 & echo $!; wait'`
and checks the echoed pid with kill(gpid, 0) before the group kill. The fix
is porting the sibling's, not inventing one.

Two things deliberately NOT done. A deadline bump cannot fix this — the
grandchild is never killed, so no amount of waiting helps — and it would
have buried the race. And the tree is now two deep rather than three
(test → helper → grandchild, not test → helper → cmd.exe → ping.exe): that
is the field shape being regressed, where the mxbuild.exe wrapper spawns one
Deno worker, and it still fails on a single-PID kill, which is the property
the test exists for.

The marker parser moved to an untagged file so its one invariant is covered
on every platform and not only in the Windows CI job: reject a line not yet
terminated by \n, because a truncated pid parses as a perfectly plausible
different pid. Control run — with the completeness guard removed,
TestGrandchildPID fails on exactly the two partial-line cases and nothing
else.

Not verifiable on this machine: the race is Windows-only, so the fix is
argued from the job log and the code, and CI is the measurement. One green
run does not settle a flake.

Blame was settled by a same-commit control rather than by reading a diff.
Commit 0710968 ran Build, Test & Lint twice, attempt 1 each time: push
(35715042749) green, pull_request (35715076433) red. Identical code,
opposite outcomes.

TestGrandchildPID does not match the CI job's -run pattern, so its
"at least 5 tests ran" guard is unaffected.

Refs #594, #601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Alias ProjectVersion instead of duplicating it
…ng script

The doctype gate (`make test-integration`) runs every script in
doctype-tests/ through exec + mx check against a blank project, and the
12-styling.mdl added in 78612b5 failed it two ways. `make test`, `make lint`
and `make check-mdl` all pass without touching that gate, which is how it
reached CI.

1. It was not self-contained. It styled `Styling.StyledPage`, a page nothing
   in the script creates: `Execution error: page not found`. Every other
   doctype script creates the module and pages it uses.

2. The design properties were native-only. `Spacing bottom` and `Full width`
   are declared for native widgets; a WEB DivContainer declares `Item gap`,
   `Card style`, `Disable row wrap`, `Background color` and friends
   (themesource/atlas_core/web/design-properties.json). The routing added in
   992dc05 refused them correctly — the code was right and the example was
   wrong, which is the outcome to prefer but only if the example is fixed
   rather than the guard loosened.

The file is removed rather than repaired: 12-styling-examples.mdl already
exists, already builds a StyleTest module with pages, and already owns the
ALTER STYLING sections these two levels belong beside. A second styling
doctype script was duplication the PR checklist asks to look for first.

The new sections use properties the web theme actually declares, and add two
data-grid pages so the bulk form has something to select. `Compact` and
`Striped` come from datawidgets' own design-properties.json, so the plural
example exercises a pluggable widget id rather than a native $Type.

Gate passes: TestMxCheck_DoctypeScripts/12-styling-examples.mdl/modelsdk, and
mx check reports 0 errors on the resulting project.

Refs #515

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYaTtzjmMCUjo8XAVN1sQx
fix(layout): CREATE OR REPLACE LAYOUT rewrites the stored unit instead of replacing it
A side-by-side test (Opus building an app on Vercel vs with mxcli) showed
4.25x the model calls and 5.4x the conversation re-read. This proposal
works out why and what to do about it.

The spine is the arithmetic: every call re-reads the transcript, so total
cost is calls x conversation size, and when a removed call takes its tool
results with it the total falls with the square of the call count. That
sets the priority order — fewer calls first, less text per call second,
output tokens (500k of 228M) not at all.

Six levers, ordered by that arithmetic. Two findings are already actionable
against today's binary: exec runs the semantic check itself and refuses on
error, yet projectGates teaches check-then-exec as two gates in every
generated CLAUDE.md; and the 35s restart the session paid per change is
opt-in slowness next to run --local --watch and test --attach.

Sequenced with diag loop-report and a benchmark first, so the remaining
levers are falsifiable rather than plausible.

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

The first draft called the 35s restart "opt-in slowness" next to
run --local --watch. That is wrong on 11.14, and the measurement was
already in the tree: on 11.14 the first build in an mxbuild --serve
process succeeds and every subsequent build in that process fails, because
the first does not leave the deployment in a state its own incremental
build can continue from (webclient_legacy_paths.go, measured over
mxbuild's HTTP API with mxcli removed, with the one-shot, Rspack and
11.13 controls). The project that first reported it routed around it with
a restart per change — the exact pattern in the cost report.

Three consequences, now in the proposal. It does not weaken the main
lever: wall time and call count are separate axes, the defect taxes time,
and the 228M bill is calls — so `mxcli apply` collapsing 5-8 calls into 1
matters whether the build under it is warm or cold, which makes it the
only remaining lever on that axis rather than a lesser one.

Two new actionable items. `test --attach` rebuilds through the attached
app's own serve process, making its rebuild a second serve build — read
off runner_attach.go, not measured, and one run on 11.14 settles it. And
bootstrap-app defaults to the newest CDN version, so a fresh project
lands on the broken one without anyone choosing it; the default should
prefer 11.13.0 until mxbuild is fixed.

Also records the standing risk: --serve is in mxbuild --help but not in
the reference guide, so the warm loop rests on an undocumented interface
with no compatibility promise. Argues for reporting this upstream rather
than only routing around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
claude and others added 26 commits September 22, 2026 17:36
…tiered

Two objections to the first draft, both of which hold.

The agent can already chain the loop with `&&`, so the call-count win needs
nothing built. Checked that this is actually safe: exec exits non-zero on
any failed statement and docker check propagates mx check's status through
cmd.Run() rather than printing errors and exiting 0 — a chain over a
command that reported failure only in stdout would pass silently, and that
is what would make chaining unsafe. It is not present here.

What `&&` does not give is the token win: it concatenates every stage's
stdout. So output discipline is the more fundamental lever, not the junior
partner — with terse delta-shaped output, `&&` gets nearly all of apply's
value at zero new surface, and the improvement lands on every other
invocation too. apply is now contingent on diag loop-report showing the
published chain still gets composed wrong.

Second: the first draft put a Playwright run in the default chain. That is
the same mistake the cost report describes — an always-on chain trains
maximal verification, and hard-wiring the browser would turn "I tested
every admin flow, most checks included screenshots" from a choice into a
property of the tool. The chain is now tiered, stopping at the first
sufficient gate, with the routing rule that verify-in-runtime.md already
owns: most changes stop at exec or the build, and the browser row is rare
and the only one paying image input.

Sequencing rewritten: the loop-shaping item is now a documentation change,
and the only substantial build is the output work that makes it pay in
tokens rather than only in calls.

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

The draft said "Vercel's agent writes a .tsx file and the work is done",
which is false and was filing the gap under platform tax instead of work.
TypeScript needs type-checking, building and rendering too.

The difference is four properties of the verification, not whether it
happens: cost per verification (tsc ~1-3s and HMR at ZERO tool calls, vs
mxbuild ~25s and a tool call), how often it is needed (first-attempt
success, where TS is saturated in training data and MDL appears nowhere),
error locality (file:line:col vs a CE naming a document at the end of a
build), and whether the last tier needs a running app.

Three of the four are engineering, and two are already in flight.
check-vs-mxbuild parity is a standing programme with ~17 rules shipped,
each moving a construct from a 25s build to a 2s check — this proposal's
contribution is to say why that is a token lever and not only a
correctness one. Diagnostic quality is the other, and it matters here
because a vague error costs a diagnosis, which is the 40-call tail.

The real target named: build once per batch, not once per change, which is
what the Vercel agent does with tsc. The blocker is trust rather than
speed — an agent builds after every change because check passing does not
yet mean the build passes, and #568 (docker check reporting 0 errors over
a build-failing model) attacks that trust from the other side.

"Mendix builds are a per-change tax forever" has accordingly moved out of
the does-not-fix list. What stays there is the render tier, which is true
of React too.

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

runSemanticValidation in lsp_diagnostics.go runs the same validators as
cmd_check.go — the LSP and `mxcli check` are one checker behind two front
ends. So the LSP finds exactly what check finds, and the check-to-build
gap that forces the 25s mxbuild per change is untouched. It makes the
already-cheap tier cheaper and does nothing to the tier that dominates.

Also a wash on call count as normally used: the call it would save is the
redundant check that exec already folds in, and reading diagnostics is
itself a tool call.

The one condition under which it becomes real: diagnostics riding along
with the Write/Edit result at zero extra call, which is the property the
asymmetry table identifies as the Vercel agent's biggest structural
advantage. Wired that way it is the only available route to a zero-call
verification tier for static errors; wired any other way it is a front end
onto a command we have.

Records the failure mode too — auto-attached per-edit diagnostics are paid
on every intermediate save of a script that is incomplete until its last
line, so they want to fire on batch completion rather than per edit.

The real argument it supports is spending on parity instead: because the
validators are shared, every rule from the check-gap programme lands in
the editor and the agent's checker at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
The proposal claimed exec folds in everything check does, making the
two-gate form a wasted call in every project. That was Track A's headline
and it is false.

There are two validation passes and exec runs only the first.
executor.ValidateProgram (package-level, semantic rules) runs in both.
exec.ValidateProgram (method, project connected — reference resolution)
and exec.CheckProjectConflicts (plain CREATE over an existing document)
run in check only, behind --references, which -p implies. So check -p
catches dangling references and create-conflicts that exec does not, and
since exec is not transactional that preflight is load-bearing.

The claim was read off cmd_exec.go's doc comment, which is accurate about
the pass it describes and silent about the two it does not. Reading the
call graph takes one more step and was skipped; that is recorded in the
proposal because it is the instructive part.

What survives is better targeted. The gate list's defect is its framing,
not the check gate: it calls the seven gates "the definition of done ...
a change is finished when they have all been run" while holding docker
check, test and run --local, which read literally mandates ~55s of gates
on every change — the maximal-verification pathology the cost report
describes, and a contradiction of the escalation rule one line above. The
fix is batching, not deletion.

Also names the one real code change in Track A: exec already connects to
the project, so it could run the reference pass in its preflight, which
would make the check gate genuinely redundant for the apply path and stop
exec half-applying a script with a dangling reference.

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

Closes #607.

`mxcli check -p` runs two validation passes and `exec` ran only the first.
executor.ValidateProgram (semantic) ran in both; exec.ValidateProgram
(reference resolution against the connected project) ran in check alone,
behind --references, which -p implies. exec already connects before its
preflight, so it had everything the second pass needs.

What that cost, measured on the expr-checker fixture with the pre-fix
binary (--no-check reproduces it) — and NOT the failure the neighbouring
refusal describes:

    create entity "NotAModule"."Thing"      exit 0, "Created module:
                                            NotAModule" — a misspelled
                                            module is silently created
    microflow retrieving a missing entity   exit 0, both documents written

exec did not half-apply; it completed, and wrote a model only a ~25s
mxbuild would reject (CE1613). So this is a check-to-build parity fix —
it moves a build-tier error to the 2s tier — and it closes a violation of
the "no silent side effects on typos" rule in CLAUDE.md's own checklist.
After the fix both scripts exit 1 with nothing written.

Safe to refuse on: the pass skips references to objects the script itself
creates, so an error means the name resolves to nothing in the project AND
is not created here. Gated on -p, since a script using its own CONNECT has
no backend at preflight time — the same condition check uses.
CheckProjectConflicts is deliberately NOT run: a plain CREATE over an
existing document is worth reporting when validating a script but ordinary
for a re-run, and refusing it would break scripts that work today.

The test states the GAP rather than that the validator works: its first
assertion is the control, showing the pass exec ran reports nothing for a
script the reference pass refuses, and it fails loudly if that ever stops
being true rather than being quietly relaxed.

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

Closes #608.

The gate section had a completeness rule ("**They are the definition of
done, not a menu** — a change is finished when they have all been run")
one line below an escalation rule ("each is only worth paying for once the
one above is clean"). They contradict each other and the bolded one wins,
applied to a list holding docker check (~25s), test (~30s cold) and
run --local. "A change" was never defined, so in practice it became each
edit: ~55s of gates and five tool calls per edit.

That is measured, not hypothetical. A session-cost comparison of the same
class of app built with mxcli vs on Vercel found 523 model calls against
123, with the per-change loop at 5-8 tool calls; every call re-reads the
transcript, so call count enters the bill quadratically once the removed
calls take their output with them. The report's own line — "I tested every
admin flow ... most of those checks included screenshots" — is this
instruction being followed, not an agent being careless.

The fix is the UNIT, not the list. Every gate stays: the three-copy tests
exist because `test` fell off this list once and became reachable only
when a user asked for it by name. What changes is that the gates are
done-criteria for a coherent unit of work — iterate with exec, then run
them once over the result. Same shape as a TypeScript agent leaning on
tsc per edit and batching the build.

Held in all three places by a new test, for the same reason the gate list
itself is: stated in two of three, it applies when someone remembers.

Also drops --references from the check gate line, which -p implies and
which the flag's own help says is kept only for compatibility, and says
what check still buys now that exec resolves references too (#607): it
does not apply. Generated file is 5857 of its 6000-byte budget.

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

The instrument the agent-loop work needs, and the first item in
PROPOSAL_agent_loop_efficiency.md's sequencing, because every lever after
it is a hypothesis until a number moves.

Every mxcli invocation already writes a session_start naming its argv
(mdl/diaglog, wired at newLoggedExecutor so it covers all commands, not a
curated subset). Nobody had read it. This reports per-command call counts,
wall time and median, plus `check` immediately followed by `exec` of the
same script.

Three things it does on purpose:

Verbs resolve through rootCmd.Find rather than a hand-kept table, so a
renamed or added command is picked up with no change here — a stale table
would report a real command as "(unknown)", which defeats a report whose
job is ranking commands by frequency.

An invocation with no session_end is counted and labelled "unclosed", not
"failed". mxcli exits through os.Exit on most failures and deferred
Close() does not run then, so the two correlate — measured against a real
log, where the 3 unclosed of 10 were exactly the 3 non-zero exits — but a
killed or still-running process looks identical, so the report says
"did not close" rather than asserting an exit code. Unclosed runs
contribute no wall time: guessing an end from the next start would inflate
the figure the report exists to make trustworthy.

It prints what it cannot answer. It counts mxcli PROCESSES, not model
calls; output bytes are recorded nowhere, which is the other half of the
bill; and `run --local` writes no session records, so reloads vs restarts
are absent. Stating those beats quietly implying the numbers are the
whole picture.

Also: the report filters its own invocations. diag does not log today, so
this cannot be caught by running the binary — it keeps the report correct
if diag ever starts. And verbStats.Unclosed is deliberately not named
Failed, since loopReport.Failed means a run that closed reporting errors:
one name for two populations is how a report starts lying.

Tests are mutation-checked, not just green: removing the self-filter and
dropping the same-script condition from the pair detector each fail their
guard with the reported symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
Lever 2 of PROPOSAL_agent_loop_efficiency.md. "Unchanged …" is the most
repeated thing mxcli prints, and a settled re-run is nothing else.

MEASURED on a 40-statement script applied to a project already holding it:
41 lines / 1,604 B, every one saying nothing happened. After: 2 lines /
177 B. That is cheap in a terminal and charged repeatedly in an agent
session, where a tool result is written into the conversation once and
RE-READ by every later model call — so a no-op line costs its length times
the number of calls that follow it.

Two rules keep the collapse honest, and each came from getting it wrong.

Only `Unchanged` collapses. It is the one verb that by construction reports
an absence (storage was offered a write and skipped it, ADR-0008), so no
line a reader would act on is replaced by a number: a mixed run still names
every real write individually and counts only the rest — measured, 2 created
+ 1 modified + 39 elided prints the three and one summary. The obvious
alternative, collapsing on volume, would hide real writes in exactly the
runs where they matter most.

The trigger is how many elisions arrive, not which entry point ran. A lone
one is printed verbatim, since "1 document already in sync" is worse than
the line it replaces. The first implementation gated on "is this a program
run?", which looked equivalent and was not: `-c` reaches ExecuteProgram too,
because executeMDL prepends a CONNECT statement, so a one-liner collapsed to
a count of one. Caught by running it, not by the tests, and now has a test
naming the measurement.

Nested EXECUTE SCRIPT does not emit a second summary mid-run: begin() reports
whether the call owns the tally, and only the outermost flushes.

Guards are mutation-checked — dropping the held line makes the lone-line test
fail with an empty report, and the control test asserts both landed writes
are still named while two elided ones are not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
First slice of #611. CLAUDE.md is re-read into every context in this repo,
and at 108,761 B (~27k tokens) it was 18x the 6,000-byte budget
init_claudemd_budget_test.go enforces on users' projects — the same
reasoning, applied outward only.

Implementation Status was 33% of the file and bimodal:

  36 bullets < 250 B   pure capability listing ("Domain model", "Pages
                       with 50+ widget types"). `mxcli syntax`, `help` and
                       `lint --list-rules` answer this authoritatively and
                       cannot go stale, so these are deleted rather than
                       relocated.
  20 bullets ~33 KB    real measurement — CE numbers, the control that
                       settled a question, the trap that cost an afternoon.
                       Moved to the skill for that doctype, where it is
                       loaded when the area is touched instead of on every
                       session.

One more was dropped as a duplicate: the idempotent-writes bullet
summarised a Key Concepts section in the same file and the docs-site page
it cited. Verified rather than assumed — every concept it named
(StableId, TransplantIDs, MXCLI_ALWAYS_WRITE, the canonical form) is still
present in CLAUDE.md.

Nothing else was lost: three distinctive probes per moved bullet were
checked against the destination after the move, and the largest deleted
bullet is 139 B.

Six moved sections cited a skill and, now living inside it, cited
themselves — three through the flat `<name>.md` path that no longer
exists. Those clauses are stripped.

108,761 -> 73,712 B (32%). Key Concepts (26%) is the next slice, and the
budget test lands once the file is under target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
The generated CLAUDE.md is budgeted at 6,000 bytes because it is re-read
into every context a project starts. 3065f68 added the once-per-change
paragraph and took it to 5,857, leaving 143 bytes — tight enough that the
next contributor to touch the file would have had to buy space from
something else.

Four lines to three, and the "~55s a time" figure goes: the gate list two
lines below already shows ~25s for docker check and ~30s cold for test, so
the reader adds up numbers that are on screen rather than being told a
total that can drift from them.

5,857 -> 5,805, headroom 195. The marker phrase the three-copy test holds
("not per edit") is unchanged, and the skill and docs-site copies keep
their own longer wording — neither is re-read per session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
Second slice of #611. Key Concepts was 26% of the file: 27,733 B, now
8,101. CLAUDE.md is 108,761 -> 53,950 across both slices (50%).

The rule applied per subsection: a concept stays only if it is
non-inferable, causes silent unrecoverable damage when violated, AND
applies regardless of what is being touched. What stays is storage names,
GUID-as-database-identity, the three conditional-write rules, the
reserved-member names, association pointer inversion, expression escaping.
Everything else is needed only when touching one subsystem.

Relocated, because the content lives nowhere else: the gen wrong-key ledger
and overlay-write rules to MODELSDK_ENGINE_ARCHITECTURE, TypeEnumeration
ambiguity to MDL_PARSER_ARCHITECTURE, pluggable widget templates to
diagnose-ce0463, AfterStartupMicroflow to project-settings, the fluent API
to README.

Compressed rather than moved where the canonical home already had it,
verified by probing distinctive claims rather than counting greps: the
tunnel section against ADR-0009 (4/6 — the two it lacked, the never-obfuscate
rule and the seam file names, are kept in the compressed text) and the theme
section against theme-styling/SKILL.md (6/7, the seventh a Go constant name
that grep finds).

That probe is why "Writes Are Conditional" was relocated instead: ADR-0008
predates the transplant work and has none of TransplantIDs, dropCollisions
or carryIdentityFromRemovedUnit, and the ADR is immutable, so the mechanism
moved to the docs-site internals page it already cited. Its three rules stay
in CLAUDE.md — each has been violated once, and #125 shipped green without
them.

Public API Pattern is dropped outright; README documents it.

Every moved section was probed in its destination after the move (21/21
distinctive strings present).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
…ve (#611)

The checklist was 11,868 B re-read into every session, and most of it only
bites when a change touches a particular subsystem: backend abstraction,
full-stack wiring, version gating, test coverage, security, docs, code
quality. Those moved into /mxcli-dev:review, which is the command that
applies them and which previously only pointed back at CLAUDE.md.

Two subsections stay, because they govern how the work is DONE rather than
how it is reviewed, and an agent that never runs the review command still
has to follow them: the bug-fix evidence bar (test written first, verified
at the layer the symptom lives in, fix proven to be the cause by reverting
it) and one-thing-per-commit.

That distinction is the whole judgement here. Moving the evidence bar too
would have made it apply only when someone remembered to ask for a review,
which is the same failure the three-copy gates tests exist to prevent.

Renaming the section broke two references to it, in CLAUDE.md's own
welcome and in review.md step 2; both updated, and a grep confirms none
are left.

11,868 -> 2,839 in CLAUDE.md; 53,950 -> 45,074 overall. Six distinctive
strings from the moved subsections verified present in review.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
Four sections were restating what a command, a directory listing or a
frontmatter description answers authoritatively — the same pattern already
removed from Implementation Status.

The 27-row CLI feature table goes: `mxcli help <command>` and `mxcli syntax`
own it. Two facts in it existed nowhere else and were relocated first — the
wrapping-grid/6,000px default for an entity with no @position, to
domain-model-layout.md, and `fix widgets` clearing CE0463 (measured 203 -> 0
on a vanilla 11.12.1 app), to diagnose-ce0463.md.

The skill list goes, on this file's own rule that a skill's frontmatter
description IS the index — the table drifted to 12 of 68 before mendixlabs#906 caught
it. The always-on MDL idioms it contained stay.

The directory tree goes; `ls` answers it. What replaces it is the
orientation the layout does not give you: that modelsdk/ is the engine and
sdk/mpr is deleted, and how mdl/'s grammar -> visitor -> ast -> executor
chain fits together.

The file index becomes routing only, for the entries whose annotation is
not derivable from the filename: the CE0463 elimination order, the
verify-in-runtime tier, the both-type-and-object template rule, and that
System-module string lengths are measured rather than taken from the Model
SDK.

Three CLI rules were kept because no --help states them: generated parser
files are not committed, skills are edited in .claude/skills/mendix/ and not
the rsync --delete'd embed dir, and mxcli-dev/ commands are not synced.

Also merges "MDL Syntax Quick Reference" into "What mxcli Can Do", which
had come to say the same thing, keeping the link.

108,761 -> 25,514 B across the four slices (76%, ~27k -> ~6k tokens). No
dangling relative links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
The last item on #611. init_claudemd_budget_test.go argued the case for a
user's project — a file re-read into every context is a per-session tax
rather than a one-off — and then applied it outward only, while the repo's
own CLAUDE.md grew to 18x that budget in the same package.

Set at 28,000, just above the current 25,514. Higher than the generated
file's 6,000 because this one legitimately carries more: the invariants
whose violation is silent and unrecoverable, and the evidence bar for a
change. Deliberately not generous — enough to edit within, not enough to
regrow into, so adding something means taking something out.

The test is mutation-checked rather than merely green: lowered to 20,000 it
fails naming the real size, so it is measuring the file and not passing
vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
Agent loop efficiency: the proposal, three fixes, and the CLAUDE.md that motivated them
fix: correct the MPR format reference docs, and hold them to real fixtures
…pixels

Closes #614.

Most screenshotting during agent work answers a textual question — did the
page render, is there an error banner, did the grid get rows. A PNG costs
~1,500 tokens to read against ~100 for a verdict, and the cost is not a
one-off: an image read into a conversation is re-read by every later model
call, so one PNG early in a long session is charged hundreds of times.

It is also the weaker instrument. Measured against two real rendered pages:

    page /p/customers  title="Customers"  h="Customer overview"  rows=2
                       text=21  console-errors=0
    page /p/customers  title="Customers"  NO VISIBLE TEXT  console-errors=1
      ERR    Cannot read properties of undefined (reading 'items')

The second is the blank-page symptom WITH ITS CAUSE NAMED. A console error
is the commonest reason a page renders blank and it does not appear in a
picture at all — that class of bug took ~40 calls to trace in the session
behind PROPOSAL_agent_loop_efficiency.md, every one of them looking at
pixels that could not show it.

--page-check prints the verdict and no PNG; --screenshot now prints it
alongside the PNG, so the cheap signal arrives whether or not it was asked
for and the image need not be opened.

No new dependency: the Playwright CLI has no text-dump subcommand, so the
probe runs under the node Playwright already needs, resolving the package
via `npm root -g`. It never throws on a bad page — a page that fails to
render is the thing being measured, not an error in measuring it.

Brevity is load-bearing, so everything unbounded is clamped and a test
holds the verdict under 600 B on a page with 5,000 rows and 40 console
errors: a verdict that grows with the page defeats its own purpose.

Also adds the routing rule to verify-in-runtime.md, which had nothing about
cost (0 mentions) — the proposal stated it and the skill never got it, so
nothing steered an agent from pixels to text.

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

The proposal listed the five Mendix limitations the cost report named and
proposed turning each into a check diagnostic or skill. That was written
from the report's framing without verifying any of them. Checking all five
afterwards:

  inputs in lists read-only   an MXCLI BUG, fixed 2026-09-06 — List View
                              has its own Editable (default No) that wins
                              over the textbox's; buildListViewV3 never
                              read it and the writer wrote false
  popup styling               covered in theme-styling/SKILL.md
  scripted login input        covered in test-app/SKILL.md, with the
                              playwright-cli eval workaround
  wrong Java version          handled in docker/javaversion.go (11.14 is
                              the first wanting 25 rather than 21)
  stale sidebar               the only one still open, and it is runtime
                              refresh behaviour with no static signal

So the lever as written would have shipped one diagnostic that is now
actively wrong — inputs in lists ARE editable, and saying otherwise sends a
reader back to a pop-up workaround they no longer need — and three that
duplicate skills that already exist.

What the correction reveals is a ROUTING problem rather than a knowledge
one: the knowledge was there, in the skill whose description is supposed to
surface it, and the session hit the wall anyway. Same failure as the skill
table drifting to 12 of 68 (mendixlabs#906) — an index that does not route is
indistinguishable from missing content.

The principle survives; the lesson measuring adds is the prior step. Check
whether it is already known, and whether it is even true, before encoding
it. A platform limitation that was really a bug outlives the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
The nightly failed on Mendix 10.24 in TestMxCheck_DoctypeScripts:

  alter settings workflows add group '<name>' requires Mendix 11.2.0+
  (project is 10.24.24.119349)

Examples 4.3-4.7 in 14-project-settings-examples.mdl were added ungated,
though Settings$WorkflowGroup is 11.2 metamodel and the executor rightly
refuses it on an older project. Wrap them in a `-- @Version: 11.2+`
section, with the directive above the doc comment so it isn't orphaned.

Reproduced against mxbuild 10.24.24.119349: the test fails unfixed with
the nightly's exact error and passes with the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NcJgoFWn5vFoQ2Zw8dp3DE
Answer the screenshot's question in text, and correct a proposal lever that measurement disproved
fix(examples): gate the workflow-group examples to Mendix 11.2+
… log

Closes #617. Found by testing loop-report against a real project
(mxcli-ledger FINDINGS phase 41, finding 153); reproducing it here showed
the hole was wider than reported.

`loop-report` prints "mxcli invocations: N" as though it counted every run.
It counted only commands that built a logged executor — 14 files against 53
registered commands — and only when the run survived long enough to reach
that code. Measured, watching invocations/unclosed move:

    -c against a missing .mpr        exit 1   +1 / +1
    check against a missing file     exit 1   +0 / +0
    check on a REAL file, no -p      exit 0   +0 / +0

The third row is the part the ledger did not see: newLoggedExecutor("check")
sits inside the `if checkRefs` block, so a check without -p never logged at
all. That makes the ledger's own headline — 250 of 301 invocations are
`check` — an undercount, which strengthens its conclusion rather than
weakening it.

What it hid is the point: burning calls on wrong paths and missing files is
a characteristic agent failure and is exactly what this report is opened to
see. `failed` stayed 0 while real non-zero exits happened.

Now initialised once per process from PersistentPreRun, before argument
validation, so an invocation is recorded whatever the command and however
early it fails. Init is a singleton, so the commands that build a logged
executor later get the same logger instead of opening a second session.

The first attempt fixed counting and broke the signal: with no matching
close, a SUCCESSFUL check was recorded as unclosed, turning every clean run
of a non-logging command into a false failure. PersistentPostRun closes it,
and cobra skips that on os.Exit — which is how nearly every failure path
ends, so "no session_end" remains the tell. Verified in both directions:
successes counted and closed, failures counted and flagged.

Making Init a singleton broke isolation in three existing tests, which had
relied on it always creating fresh state; they now reset explicitly.

Also corrects the report's own caveat, which named `run --local` as the
exception and so implied everything else was covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
Closes #618. Reported in mxcli-ledger FINDINGS phase 41 (finding 154) and
reproduced verbatim.

    $ cat bad.mdl
    this is not valid mdl at all;
    /
    $ mxcli check bad.mdl                 Check passed!   exit 0
    $ mxcli exec bad.mdl -p Ledger.mpr                    exit 0, nothing applied

Root cause, confirmed at the visitor: Build() returns zero statements AND
zero errors for text the grammar cannot begin to parse, so it is
indistinguishable from an empty file — and an empty script is legitimately
a pass. It is not a lenient parser: a malformed statement that STARTS with
a keyword is still caught.

So a truncated file, a lost heredoc, or a path that resolved to the wrong
kind of file passed both gates and moved nothing, silently, at exit 0. For
a workflow whose premise is that mdlsource/ replays, that is the worst
available outcome — the replay reports success and the model does not
change — and an agent cannot see it, because both commands it would use to
check say everything is fine.

Both gates now refuse zero statements from non-empty input and name the
first line that did not parse, since "the parser never got into your file"
is only actionable if it says where. exec refuses rather than warns: "apply
this file" that applies nothing is never what was meant.

Three controls keep the guard narrow, and are tested: an empty file, a
whitespace-only file and a comments-only file all still pass, because each
is a legitimately empty script.

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

`diag loop-report --json` emitted `failed`, which stayed 0 across a real
442-invocation log while runs were genuinely exiting non-zero. Beside
`"unclosed": 10` in the same object it reads as "nothing failed" — the
opposite of the truth.

The counter was correct; the name described a different population. It fires
only for an invocation that CLOSED while its summary reported errors, and that
count is diaglog's statement-level counter — reachable only via
`exec --continue-on-error`. A run that actually fails exits through os.Exit,
skipping the deferred Close() and PersistentPostRun, so it writes no
session_end and lands in `unclosed`. The two populations are disjoint by
construction.

  failed -> runs_with_statement_errors (JSON), Failed -> StatementErrors (Go)

and the text report now prints it when non-zero, stated as separate from the
unclosed count. Zero is suppressed: a 0 beside a non-zero unclosed count is
the misreading itself. renderLoopReport takes io.Writer so that output is
testable.

Making `failed` mean failed is the larger change — an exit-code path through
~250 os.Exit sites, since Go has no atexit. `unclosed` carries that signal
today and the report explains what it is.

Proven by revert both ways: restoring the JSON tag fails the key test with
`"failed":2` in the payload; relaxing the guard to >= 0 prints
`Finished with failed statements: 0` directly under `Did not close: 2`, which
is the reported symptom exactly.

Closes #620

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
`make check-mdl` went red on 15-fragment-examples.test.mdl with #618's
message: "the parser could not begin reading it. First line that did not
parse: create module FragTest;" — a line the parser was never handed. The
same 416 bytes under a plain .mdl name parse fine, at 18 statements.

Two defects, stacked.

**Mine.** A .test.mdl is not top-level MDL: each block is a microflow body,
so `check` parses testrunner's RENDERING of it (mendixlabs#1103). The #618 guard was
given `string(content)`, the file as read. A file with no @test block renders
to nothing, so the guard compared zero statements against the non-empty
ORIGINAL and quoted a source line from text the parser never saw. It now runs
on `source` — whatever was actually parsed — and an empty rendering from
non-empty content gets its own message naming what is really missing.

**Pre-existing, and what the guard found.** That fixture declares no @test at
all; it is a syntax demo misnamed .test.mdl, its sibling
15b-fragment-slots-examples.mdl being plain. So it rendered to nothing, check
printed "Check passed!" on zero statements, and check-mdl had been reporting
PASS over 416 lines nothing ever read — concealing a real MDL-PAGE20
violation (page parameter $Customer, url with no {Customer} segment). #618 is
"a silent no-op is the worst outcome", and the repo's own suite held an
instance. Renamed to .mdl, url fixed, now genuinely checked.

roundtrip-issues.md listed it under "require Docker runtime", which was never
true — it needed no runtime and was checked by nothing. The list is a record
of a past manual run, so the entry stands with the correction beside it
rather than being rewritten.

Proven by revert end-to-end: restoring `string(content)` reproduces the CI
message verbatim on the same bytes under the .test.mdl name. The new test
asserts the predicate check branches on (the rendering being empty) with a
control showing the same statements inside an @test block do render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ
Three defects that testing loop-report against a real project turned up
@github-actions

Copy link
Copy Markdown

AI Code Review

We are reviewing a PR titled "Sync ako/mxcli: agent loop efficiency, page/widget writes, workflow fixes"

The PR has 38 commits and includes:

  • Agent loop efficiency improvements (including new commands: diag loop-report, run --page-check, collapsing unchanged reports, and reducing CLAUDE.md size)
  • Pages, widgets, and layout changes (e.g., ALTER PAGES ... WHERE WIDGETTYPE, ALTER PAGE ... SET, layout rewrite preserving unit, UPDATE WIDGETS not reporting success after writing nothing, and refusing primitive snippet parameters)
  • Workflow fixes (overview page storage, accepting clauses in any order, gating workflow-group examples to Mendix 11.2+)
  • Corrections backed by measurement (fixing $Type storage names, MPR v1 detection, System-module ceiling advice, ProjectVersion aliasing)
  • CI improvements (streaming go test output, fixing Windows grandchild marker)

We are to review against the checklist in CLAUDE.md.

Let's break down the review by the checklist sections:

Overlap & duplication

We need to check for duplicate implementations. The PR introduces several new features and fixes. We should look for:

  • New commands: diag loop-report, run --page-check (via run --page-check flag, but note the PR says it's a new flag for run), and the collapsing of "Unchanged" reports.
  • New test files and example files.

Looking at the diff, we see:

  • Added cmd/mxcli/diag_loop_report.go and cmd/mxcli/diag_loop_report_test.go for the new diag loop-report command.
  • Added cmd/mxcli/docker/pagecheck.go and cmd/mxcli/docker/pagecheck_test.go for the --page-check flag in run.
  • Added many bug test files in mdl-examples/bug-tests/ (e.g., for snippet primitive parameter, layout replace, workflow clause order, etc.)
  • Added doctype test modifications (e.g., renaming 15-fragment-examples.mdl to 15-fragment-examples.test.mdl and then fixing it).

We must check if these duplicate existing functionality.

From the PR description:

  • diag loop-report is new and uses existing session logs (so no duplication of functionality, just a new way to analyze).
  • run --page-check replaces using a screenshot with a text verdict (so it's a new feature, not a duplicate).
  • The collapsing of "Unchanged" reports is a change to existing behavior, not a duplicate.

We don't see obvious duplication of existing commands or features.

MDL syntax design

The PR introduces new MDL syntax? Let's see:

From the PR:

  • ALTER PAGES … WHERE WIDGETTYPE bulk-sets design properties across pages.
  • ALTER PAGE … SET writes Atlas design properties.

These are modifications to existing ALTER PAGE syntax? Actually, the PR says:

  • ALTER PAGES … WHERE WIDGETTYPE is a new bulk form.
  • ALTER PAGE … SET is extended to write Atlas design properties (previously it might have been for other properties?).

We must check if the new syntax follows the guidelines.

Looking at the diff for MDL changes:

  • In mdl/grammar/MDLParser.g4: we see modifications (34 lines added, 0 deleted? Actually the diff shows modifi +34 -0 for MDLParser.g4, but note the diff is selected and we don't see the full change). However, we see changes in the workflow grammar (mdl/grammar/domains/MDLWorkflow.g4 modifi +60 -34) and page grammar (mdl/grammar/domains/MDLPage.g4 modifi +6 -8).

We must check the new syntax for:

  • Standard CRUD verbs: The PR uses ALTER for bulk setting and for setting design properties on a page. This is consistent.
  • Qualified names: The WHERE WIDGETTYPE clause uses a widget type (which is a keyword or full widget id) - not a qualified name in the Module.Element sense, but widget types are not Module.Element. However, note that the widget type in the WHERE clause is resolved to a widget id (see resolveWidgetTypeSelector in the new cmd_alter_pages_styling.go). This is acceptable because widget types are not domain model elements.
  • Property format: The ALTER PAGE ... SET uses ( key: value, ... ) format? We see in the PR that it's setting design properties. The design properties are set via the existing ALTER PAGE SET mechanism? Actually, the PR says ALTER PAGE … SET writes Atlas design properties. We don't see a change in the grammar for the SET clause itself, but rather a new statement ALTER PAGES ... SET ... WHERE WIDGETTYPE.

Looking at the new AlterPagesStylingStmt in mdl/ast/ast_alter_page.go:

type AlterPagesStylingStmt struct {
	Module      string              // "" = every module the project owns
	Assignments []StylingAssignment // reuses ALTER STYLING's assignment shape
	WidgetType  string              // MDL keyword (`datagrid`) or a full widget id
	DryRun      bool                // report the matches and write nothing
}

And StylingAssignment is reused from ALTER STYLING (which is for snippets?).

The property format for the assignments would be inherited from ALTER STYLING, which uses ( key: value, ... ).

We must check if the new statement follows the pattern:

  • Verb: ALTER PAGES (which is a bulk form of ALTER PAGE)
  • Then SET (standard verb for setting properties)
  • Then a list of assignments in ( key: value, ... ) format
  • Then WHERE WIDGETTYPE = <kw> (which is a condition)

This seems to follow the pattern.

However, note that the condition uses WIDGETTYPE (which is a keyword) and then an equals and a value. The value can be an MDL keyword (like datagrid) or a full widget id (like com.mendix.widget.web.datagrid.DataGrid2).

We must also check that the statement reads as English: "ALTER PAGES SET 'Row size' = 'Small' WHERE WIDGETTYPE = datagrid" - which is understandable.

We don't see any keyword overloading or symbolic syntax.

The PR also mentions workflow changes: allowing clauses in any order. This is a change to the CREATE WORKFLOW statement, making the header clauses order-free. This does not introduce new syntax but changes the meaning of existing syntax (making it more flexible). We must check that it still follows the guidelines.

The workflow changes are in the visitor and parser. The grammar for workflow was changed to allow clauses in any order (by making each clause optional and not position-dependent). This is acceptable and does not violate the syntax design guidelines because it doesn't add new keywords or change the verb.

Full-stack consistency for MDL features

We must check that any new MDL feature is wired through the full pipeline.

New MDL features in this PR:

  1. ALTER PAGES ... SET ... WHERE WIDGETTYPE (bulk styling)
  2. The change to allow workflow header clauses in any order (this is a change to an existing feature, so we must check the pipeline for the existing feature is still intact, and the change is made throughout)

For the bulk styling:

  • Grammar: We see changes in mdl/grammar/MDLPage.g4 (modifi +6 -8). We must check if a new rule for alterPagesStylingStatement was added.
  • AST: We see a new struct AlterPagesStylingStmt in mdl/ast/ast_alter_page.go.
  • Visitor: We see changes in mdl/visitor/visitor_alter_page.go (modifi +42 -0) and mdl/visitor/visitor_alter.go (modifi +4 -0). We must check if the visitor builds the new AST node.
  • Executor: We see a new file mdl/executor/cmd_alter_pages_styling.go and changes in mdl/executor/cmd_alter_page.go (modifi +15 -0) to handle the new statement.
  • Backend: The executor uses ctx.Backend.OpenPageForMutation and related methods, which are implemented in mdl/backend/modelsdk/ (we see changes in mdl/backend/modelsdk/layout_write.go, mdl/backend/modelsdk/page.go, mdl/backend/modelsdk/page_write.go, etc.). However, note that the bulk styling statement does not require a new backend method because it reuses the existing page mutator for each widget.
  • LSP: We don't see any changes to LSP in the diff (but note the diff is selected). We must check if the LSP was updated for the new statement. The PR doesn't mention LSP, so we might need to check if it's required. However, the checklist says: "LSP wiring (cmd/mxcli/lsp.go) if the feature adds formatting, diagnostics, or navigation". This feature does not add formatting, diagnostics, or navigation, so it might not require LSP changes.
  • DESCRIBE roundtrip: Does DESCRIBE output re-executable MDL for this new statement? We don't see any changes to the DESCRIBE functionality in the diff. We must check if the DESCRIBE for a page that has been styled via this new statement would output the ALTER PAGES ... SET ... WHERE WIDGETTYPE statement. If not, then it breaks the roundtrip.

Looking at the PR description, it doesn't mention DESCRIBE for this feature. However, note that the existing ALTER PAGE SET for design properties is already round-trippable? We don't see any changes to the DESCRIBE for pages in the diff.

We see changes in mdl/executor/cmd_pages_describe.go (modifi +46 -18) and mdl/executor/cmd_pages_layout_v3.go (modifi +41 -16). These might be related to describing pages and layouts, but not specifically for the bulk styling.

We must verify that the DESCRIBE for a page that has been styled by the bulk statement would output the bulk statement. If it outputs individual ALTER PAGE SET statements per page, then it is still round-trippable (because applying the individual statements would have the same effect). However, the bulk statement is more efficient and the DESCRIBE should ideally output the bulk statement to be concise.

But note: the bulk statement is a new way to express the same set of changes. The DESCRIBE could output either form and still be correct. However, the checklist says: "if the feature creates artifacts, DESCRIBE should output re-executable MDL". The feature (the bulk statement) does not create a new artifact; it's just a different way to express a change to existing artifacts (pages). So as long as the DESCRIBE outputs some re-executable MDL that produces the same change, it is acceptable.

Given the complexity and the fact that the PR is large, we might need to flag this as a potential issue if we don't see DESCRIBE updated.

However, note that the PR does not mention any issue with DESCRIBE, and the existing DESCRIBE for pages might already output the individual SET statements. Since the bulk statement is just an optimization, it might be acceptable for DESCRIBE to output the individual statements.

But let's look at the diff for cmd_pages_describe.go: we see changes, but without the full context it's hard to tell.

Alternatively, we can note that the PR does not add a new artifact type, so the DESCRIBE roundtrip requirement might be satisfied by the existing DESCRIBE for pages.

For the workflow clause order change:

  • This is a change to an existing feature (CREATE WORKFLOW). We must check that the pipeline is still intact.
  • Grammar: changed in mdl/grammar/domains/MDLWorkflow.g4 (modifi +60 -34) to allow clauses in any order.
  • AST: we see changes in mdl/ast/? The diff doesn't show changes in mdl/ast/ for workflow, but we see changes in the visitor and executor.
  • Visitor: major changes in mdl/visitor/visitor_workflow.go (modifi +161 -153) and new test files.
  • Executor: changes in mdl/executor/cmd_alter_page.go? Actually, we see changes in mdl/executor/cmd_alter_page.go (modifi +15 -0) but that's for pages. For workflow, we see changes in mdl/executor/cmd_workflow.go? The diff doesn't show that file, but we see changes in mdl/executor/executor.go (modifi +24 -0) and mdl/executor/cmd_alter_page.go is for pages.

We see a new file mdl/executor/design_property_routing.go and its test, but that's for pages.

For workflow, we see:

  • mdl/executor/cmd_alter_page.go is not for workflow.
  • We see mdl/executor/cmd_pages_builder_v3.go (modifi +28 -6) and mdl/executor/cmd_pages_layout_v3.go (modifi +41 -16) - again for pages.

We must look for workflow executor changes. The diff shows:

  • mdl/executor/cmd_widgets.go (modifi +119 -22)
  • mdl/executor/design_property_routing.go (added)
  • mdl/executor/exec_context.go (modifi +5 -0)
  • mdl/executor/executor.go (modifi +24 -0)
  • mdl/executor/mutation_tally.go (added)
  • etc.

But we don't see an obvious workflow executor file. However, note that the workflow executor might be in mdl/executor/cmd_workflow.go? It's not in the diff.

Alternatively, the workflow execution might be handled by the same mechanism as other statements via the executor dispatcher.

We see in mdl/executor/executor.go changes (modifi +24 -0) which likely include the dispatcher.

Given the complexity, and since the PR is about fixing the clause order (making it free), we assume the executor was already handling the workflow statement via the backend and the change only affected the visitor (building the AST) and the executor already used the AST fields (so no change needed in executor). This is acceptable.

We do not see any LSP or DESCRIBE changes for workflow, but again, workflow is not an artifact that DESCRIBE outputs? Actually, we have SHOW WORKFLOWS and DESCRIBE WORKFLOW. We must check if the DESCRIBE for a workflow outputs re-executable MDL that reflects the clause order freedom.

We see changes in mdl/executor/cmd_pages_describe.go and mdl/executor/cmd_pages_layout_v3.go but not for workflow. We see mdl/executor/cmd_workflow.go is not in the diff, so it might not have been changed.

However, note that the PR adds test files for workflow in mdl-examples/bug-tests/ (e.g., workflow-586-clause-order.mdl and workflow-586-clause-order-canonical.mdl). This suggests that the DESCRIBE for workflow might have been updated to output the canonical order? Or the test is for the visitor.

Without seeing the DESCRIBE for workflow, we cannot be sure. But the PR description says: "CREATE WORKFLOW clauses are accepted in any order" and the test files are in bug-tests, so it's likely that the DESCRIBE was not changed and still outputs in a fixed order. However, the requirement for DESCRIBE roundtrip is that if you DESCRIBE a workflow and then EXECUTE the output, you get the same workflow. If the DESCRIBE outputs in a fixed order and the workflow was created in a different order, then executing the DESCRIBE output would produce a workflow with the same meaning (because the clauses are order-free) but the text would be different. This is acceptable because the meaning is the same.

Therefore, the DESCRIBE roundtrip is satisfied in terms of semantics, even if the text is not identical.

Test coverage

We see:

  • New packages: cmd/mxcli/diag_loop_report has a test file.
  • New executor commands: diag loop-report has a test.
  • We see many new bug test files in mdl-examples/bug-tests/ (e.g., for snippet primitive parameter, layout replace, workflow clause order, etc.)
  • We see changes to doctype test files (e.g., 15-fragment-examples.mdl was renamed and then fixed).

We must check that there are no time.Sleep for synchronization. We don't see any in the selected diff, but we should note that the PR adds a test for the loop report that uses time, but it's using fixed times in the test records (not sleeping).

Security & robustness

We don't see any obvious security issues in the selected diff. The new diag_loop_report reads log files and does not execute anything. The pagecheck uses Playwright via node, but it's running a fixed script and does not take user input for the script.

We see that the Windows grandchild marker was fixed (in cmd/mxcli/docker/procgroup_windows_test.go and related files). This was a robustness issue.

Scope & atomicity

The PR has 38 commits and covers multiple areas: agent loop efficiency, pages/widgets, workflows, and corrections. The PR description says: "The bulk is one investigation — why an agent-driven mxcli session costs several times what the same app costs on Vercel — plus the fixes it turned up. The rest is pages/widgets, workflows and a set of corrections to documentation that was measurably wrong."

This suggests that the PR is not scoped to a single feature or concern. However, the agent loop efficiency work and the fixes it turned up are related (the fixes were found by investigating the loop efficiency). The pages/widgets and workflows fixes are separate


Automated review via OpenRouter (Nemotron Super 120B) — workflow source

@ako
ako merged commit 02e50d5 into mendixlabs:main Sep 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants