Skip to content

Six fixes: a write path that blocked every in-place page edit, a runtime that would not start, and four checks that fired too late - #1039

Merged
ako merged 27 commits into
mendixlabs:mainfrom
ako:main
Sep 4, 2026
Merged

Conversation

@ako

@ako ako commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Six independent fixes from the fork. Each one was reported by a real symptom, reproduced first, and has a control proving the fix is what changed the outcome.

Three of them share a shape worth calling out: mx check reported 0 errors and the thing was still broken. A build that passes is not evidence for these.

The one that blocked writing

  • canon: one translation element was carried onto several texts. Every in-place edit of a page was refused — refusing to write unit …: 1 element id(s) are used more than once, one Texts$Translation id held by eight elements. GRANT VIEW ON PAGE and ALTER PAGE … INSERT failed while CREATE OR REPLACE PAGE succeeded, because that rewrites the unit instead of patching it. So the page always looked right and only UPDATEs were blocked, which is why it survived so long.

The one that broke the runtime

  • Associations: SQL referential actions, and the message PREVENT needs. An association written with DELETE_BEHAVIOR PREVENT produced an app whose runtime would not startNoSuchElementException: None.get in SchemeFactory.setDeleteBehavior. mx check reports 0 errors either way, so nothing before boot catches it.

Four checks that fired too late, or not at all

  • Sorting on an inherited attribute was not expressible in either direction: the natural spelling produced CE1613 "attribute no longer exists", and the qualified spelling was rejected earlier. Sorting now resolves through the attribute's declaring entity.
  • MDL062 stood down for a whole microflow whenever the header carried a returns T as $Var clause, which hid a real CE0068 "End events cannot be placed inside a loop." The exemption is removed; the rule has existed for this exact case since mxcli check accepts seven constructs that Studio Pro rejects (CE0038/CE0068/CE0079/CE0711/CE0249/CE0111 + unvalidated icon glyphs) #893.
  • A data view's own OnClick: was silently dropped (MDL-WIDGET23). It parses, passes check, and exec writes the page without a word — but mxcli only writes that property for containers, buttons and navigation-list items, so the rendered element has no handler and no role="button". Measured on 11.13 by writing one page with four widgets and reading back which kept it.
  • AfterStartupMicroflow accepted a microflow with no return type (MDL073), failing the build with CE0142 "After startup microflow should return a boolean". refactor: remove ExecContext.executor back-pointer #274 already catches a misspelled name here; this one resolves perfectly, and the constraint is on the thing the setting names rather than on the setting.

Not included: the project-brain work already came across in #1022

claude and others added 23 commits September 4, 2026 05:31
Every in-place edit of a page was refused, while a full rewrite worked:

    refusing to write unit …: 1 element id(s) are used more than once
      c540fbf0-… held by [Texts$Translation ×8]

`GRANT VIEW ON PAGE` failed, `ALTER PAGE … INSERT` failed, and `CREATE OR
REPLACE PAGE` did not — it rewrites the unit rather than patching it. So
the page looked correct and only UPDATES were blocked, which is why it
took enabling a second language to surface at all.

CarryTranslations pairs a rebuilt text to its stored translations by
SOURCE STRING when the two documents' text paths differ, and mergeText
appended the stored element verbatim — deliberately, because keeping the
stored $ID is what lets no-op elision fire. When several rebuilt texts
share one source string (eight copies of the literal '{1}' on a page is
entirely ordinary) they all resolve to the same stored set, and every one
got the same element, id included.

The first use keeps the stored id; each further copy gets a deterministic
derived one. Derived rather than random so the same inputs give the same
bytes, and the visit order is sorted rather than map order — otherwise
which text keeps the stored id varies per run and the document churns.

Re-identifying a copy is safe here in a way that deduplicating ids in
general is not, and that distinction is the argument for doing it at all.
An $ID is a pointer target, and rewriting one means finding every
reference to it (ADR-0008) — which is exactly why the write-time guard
refuses rather than repairs. Nothing references a Texts$Translation: it is
a leaf child of a Texts$Text with four keys and no identity anything
resolves by, so there are no references to miss.

The guard in duplicates.go recorded that the cause of the reported case
could not be established — two explanations proposed, both withdrawn. This
is it. That comment is corrected, and the guard stays: it is cheap, it is
the only thing between a write and an unopenable project, and nothing says
Texts$Translation was the only way to get here.

Verified end to end on a real 11.13 project with de_DE enabled and three
widgets sharing a caption. Pre-fix: one id used 3x, and the next ALTER
PAGE is refused with the reporting project's message verbatim. Fixed: 27
distinct ids for 27 elements, the ALTER PAGE succeeds, and mx check is 0
errors.

Three controls, because the fix trades against the property the verbatim
append existed for. The German translation still arrives, so this is not a
"fix" that stopped carrying anything. A second identical run still reports
`Unchanged page` with the same sha and mtime, so elision still fires. And
stubbing reuseSafeID reproduces the duplicate.

Reported as CapTrackV2 FINDINGS §30 and §17 (one root cause).

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

Sorting a list on an attribute the entity INHERITS was not expressible in
either direction:

    retrieve $Users from App.AppUser sort by Name asc;
      -> mxcli check and exec both pass, then mxbuild:
         [CE1613] "The selected attribute 'App.AppUser.Name' no longer exists."

    retrieve $Users from App.AppUser sort by System.User.Name asc;
      -> mxcli refuses: "sort by attribute 'System.User.Name' does not
         belong to entity 'App.AppUser'"

So the spelling that wrote was wrong and the spelling that was right was
refused. Mendix resolves a sort reference against the entity that DECLARES
the attribute; mxcli qualified a bare name with the entity being retrieved,
and treated any other entity in a qualified name as foreign — inferring
association traversal steps, finding none, and refusing. An ancestor is not
a traversal: the attribute is on the object already.

Both halves now consult the generalization chain. The interesting part is
that neither needed new machinery: flowBuilder has carried
resolveAttributeInEntityHierarchy and entityIsSubtypeOf all along, and this
path simply did not call them. The tell was in the report — reading the
same attribute worked, because the page builder has walked the chain for
years (declaringEntityFor). When one path resolves a name and its sibling
does not, the resolver usually already exists.

Verified end-to-end on a real 11.13 project with `extends System.User`.
Pre-fix: stores App.AppUser.Name, mx check reports CE1613. Fixed: stores
System.User.Name, mx check 0 errors, and the qualified spelling that was
refused now executes.

Two controls, both of which a careless fix would break: an attribute the
entity declares ITSELF is still qualified with that entity, and an
attribute on an unrelated entity is still refused rather than passed
through to become a CE1613 at the far end of a build.

Reported as CapTrackV2 FINDINGS §13.

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

An association written with DELETE_BEHAVIOR PREVENT produced an app whose
RUNTIME WOULD NOT START. Not a build error — `mx check` reports 0 errors
either way:

    ERROR - M2EE: An error occurred while initializing the Runtime: None.get
    java.util.NoSuchElementException: None.get
      at …SchemeFactory$.…$setDeleteBehavior(SchemeFactory.scala:515)

mxcli wrote ChildDeleteBehavior "DeleteMeIfNoReferences" with a NULL
ChildErrorMessage, and MDL had no syntax for the message at all.

The field is CONDITIONAL in Studio Pro's own dialog — it appears only once
that third radio button is selected — which is why it went unnoticed, and
why a screenshot of the association properties nearly falsified the
(correct) diagnosis. A census of 47,789 units across 122 projects found 423
delete behaviours and NOT ONE using this one, so no reference existed
anywhere until one was authored for this fix.

That reference (ako/TestApp, Mappings.Order_Customer) pins the shape:

    "ChildDeleteBehavior": "DeleteMeIfNoReferences",
    "ChildErrorMessage": { "$Type": "Texts$Text",
                           "Items": [ 3, { "$Type": "Texts$Translation",
                                           "LanguageCode": "en_US",
                                           "Text": "…" } ] },
    "ParentErrorMessage": null

Two things it settles that reasoning would not: the item collection's
typed-array marker is 3, and the other side stays null because that side is
still "keep". So the element is written for that behaviour only, and only
on the child side.

The syntax is SQL's, because Mendix's three behaviours ARE SQL's
referential actions and MDL's FROM/TO already matches a foreign key's
direction — measured on the same reference: ParentPointer -> Order (the FK
owner, the FROM), ChildPointer -> Customer (referenced, the TO):

    FROM Shop.Order TO Shop.Customer
      ON DELETE RESTRICT
        ERROR_MESSAGE 'A customer with orders cannot be deleted';

That reads the way it does in CREATE TABLE, with no knowledge of which
side Mendix calls the child. The old spelling gave none: MDL's names are
Mendix's with the word "Me" dropped, and Me was the only word saying whose
deletion was being described. DELETE_BEHAVIOR still parses and still means
the same thing, and can take ERROR_MESSAGE too, so no existing script
breaks; DESCRIBE emits the ON DELETE form because it says which side is
governed.

ERROR_MESSAGE rather than ERROR: the latter is already a token, and the
compound mirrors Studio Pro's own label. SQL's RESTRICT has no custom
message, so this clause is a Mendix extension rather than borrowed.

Also removes three ast.DeleteBehavior values no grammar rule could produce
and which named nothing Mendix has — the trap behind upstream mendixlabs#901, where
String() was used as a storage encoding.

Verified end-to-end on 11.13. A project authored by the pre-fix binary
reproduces `None.get` at boot verbatim; the fixed one writes a delete
behaviour byte-identical to Studio Pro's (the legacy engine matches
including key order; modelsdk differs only in gen's pre-existing
Parent-before-Child property order, which every association mxcli has ever
written already has). mx check 0 errors, and DESCRIBE round-trips the
message through the parser.

The empty-message shape is INFERRED, not measured — the reference captures
a filled-in message, and nobody has saved one with the box cleared. Flagged
at deleteErrorText.

Reported as CapTrackV2 FINDINGS §1.

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

The bootstrap-app skill pinned `11.13.0` as the Mendix version default, which
is wrong in both directions: it ages, and it ignores what the session already
has. A Claude Code session image can bake in an MxBuild — this one carries
11.13.0 — and asking for a different version turns a no-op into a
multi-hundred-MB download of both the MxBuild and the runtime tarball.

The rule is now: use whatever is already in ~/.mxcli/mxbuild/, and otherwise
the newest version on the CDN. There is no environment variable to read (mxcli
defines none, and `mxcli new --version` has no default of its own), so the
cache directory is the signal:

    ls ~/.mxcli/mxbuild/ 2>/dev/null | sort -V | tail -1

A version the user names still wins over both.

The CDN fallback names 11.14.0 — verified, along with the fact that it is
current: mxbuild-11.14.0 and mendix-11.14.0 both answer 200, 11.15.0 answers
404. It is written as perishable and the check is parameterised on $V rather
than repeating a literal that will rot the same way 11.13.0 did.

Only the skill source changes; cmd/mxcli/skills/ is gitignored and regenerated
by `make sync-skills`, which was run.

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

A `return` inside a `loop` passed `mxcli check` and `exec`, and mxbuild then
rejected it:

    [CE0068] "End events cannot be placed inside a loop."

MDL062 exists to catch exactly that and has since mendixlabs#893. It did not fire,
because it stood down for the WHOLE microflow whenever the header carried a
`returns T as $Var` clause. Two claims justified that:

  1. buildFlowGraph synthesizes the End event from the variable, so none lands
     inside the loop.
  2. Measured: the shape builds CE0109 ("Undefined variable") rather than
     CE0068, so firing here would mislabel a different defect.

The first was never true — `describe microflow` shows the in-loop `return`
written either way. The second was an artefact of the reproduction. mxbuild
reports ONE error per microflow, and the microflow that was measured never
assigned its AS variable, so CE0109 won the race and hid the CE0068 underneath.
Re-measured on mxbuild 11.13.0, adding a `declare` for that variable and
changing nothing else:

    as-clause, $Done unassigned  ->  CE0109 "Undefined variable 'Done'."
    as-clause, $Done assigned    ->  CE0068 "End events cannot be placed
                                            inside a loop."

Same body, same loop, same return. So the exemption is deleted rather than
narrowed, and its test is inverted with the masking recorded in the comment —
a test asserting "clean" against a source that was never clean is how this
shipped.

The control is the half a careless fix would break: an as-clause microflow
whose loop does NOT return must stay silent, since the clause's real effect
(the terminal End event taking its value from the variable) is unchanged. Both
halves are in mdl-examples/bug-tests/captrack-19-return-in-loop-as-clause{,.fail}.mdl.

Reported as CapTrackV2 FINDINGS §19.

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

A data view's own `OnClick:` parses, passes `mxcli check`, is written by
`exec` without a word, and the rendered element has no handler and no
role="button".

`OnClick:` is an alias for `Action:` — the visitor stores both under
Properties["Action"] (mendixlabs#603) — and mxcli writes that property for three widget
kinds only: container/customcontainer, the buttons, and a navigationlist item.
Measured on Mendix 11.13 by writing ONE page with four widgets and reading it
back with `describe page`:

    container    OnClick kept   Pages$DivContainer.OnClickAction
    listview     OnClick GONE   Mendix HAS ListView.ClickAction; no mxcli writer
    dataview     OnClick GONE   Mendix models no click action on a data view
    dynamictext  OnClick GONE   same

Nothing fails the build — the document is valid, the widget simply does not do
what was asked — which is why this needs saying out loud and why the severity
is warning, matching MDL-WIDGET20/21. The property allow-lists behind
MDL-WIDGET01/07 could never have caught it: they are widget-type AGNOSTIC,
the same blind spot mendixlabs#928 documented for `editable:`.

Two messages, because the remedy differs. Where Mendix models no click action
at all, a container inside the widget is the correct modelling — it renders
with tabindex/role="button". Where Mendix models one mxcli cannot write
(listview, staticimage, dynamicimage, checked against generated/metamodel),
the container is a workaround and the message says so.

The rule NAMES the types it reports rather than reporting everything outside an
allow-list of the three writers. The allow-list version looked tighter and was
wrong: running it over mdl-examples/ flagged three shipped examples, because
`mxcli check` without -p has no widget registry, so lookupWidgetDef returns nil
for a pluggable widget too and the caller's "static widgets only" branch does
not hold — `datagrid` is DataGrid 2, whose onClick the widget engine does
write. A missed warning costs nothing; a false one tells an author their
working page is broken. That case is now a control test.

Reported as CapTrackV2 FINDINGS §21.

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

`alter settings model AfterStartupMicroflow = 'Mod.MF_Seed'` accepted a
microflow with no return type, `mxcli check` passed, and the build failed:

    [CE0142] "After startup microflow should return a boolean"

#274 made ALTER SETTINGS resolve the qualified names it writes, so a
MISSPELLED microflow is caught. This one is not misspelled: the name resolves
perfectly, and the constraint is on the thing the setting names rather than on
the reference. Nothing looked at the return type. The shape that trips it in
practice is a seed/demo-data microflow wired to after-startup — it does its
work, returns nothing, and the build refuses it, well away from the statement
that caused it. `alter microflow … returns …` does not parse either, so the
remedy is DROP + CREATE (the setting survives that, being stored by name).

Two halves, one function, so they cannot drift:

  - ValidateAfterStartupReturnType runs with NO project, over a microflow the
    script itself creates. That is the usual shape — create the seed microflow,
    then wire it — and the answer is in the script.
  - validateSettingsReferences covers a microflow that was already stored,
    reading its return type from the backend.

flowSignature gained ReturnKind: its existing Returns field holds the entity
name for object/list returns and so cannot tell a Boolean from a void.

Deliberately narrow, with a control test for each limit. BeforeShutdown and
HealthCheck are NOT type-checked — their rules have not been measured here, and
asserting one on a guess is the same defect facing the other way. A microflow
whose return type cannot be established says nothing rather than refusing a
script that builds. And a Boolean after-startup microflow stays clean, which a
check that simply rejected the setting would also have to pass.

Reported as CapTrackV2 FINDINGS §6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`ACTIONBUTTON … (Action: SIGN_OUT)` was refused by the default engine:

    client action *pages.SignOutClientAction not yet supported by the
    modelsdk engine — rerun with MXCLI_ENGINE=legacy

The refusal was honest. The advice was not. The legacy writer had no case for
the action either, and its default branch is QUIET — it returns Forms$NoAction
for anything unmatched — so the recommended escape hatch produced a button that
rendered, said "Sign out", and did nothing, with `mxcli check`, `exec` and
`mx check` all clean. Measured on Mendix 11.13, `describe page` came back
`actionbutton btnOut (Caption: 'Sign out')` with no action at all, and the
stored BSON held Forms$NoAction.

Both engines now write the same document, and DESCRIBE renders `sign_out` so it
round-trips:

    { "$Type": "Forms$SignOutClientAction", "DisabledDuringExecution": true }

Two keys and no more, pinned against a Studio Pro-authored sign-out button in
ako/TestApp. That reference is provably Studio Pro's rather than mxcli's,
because until this change NEITHER engine could emit the type — which is also
why the shape could not have been guessed from the writers.

Verified with the fix reverted, one engine at a time: modelsdk fails with the
refusal verbatim, legacy fails by writing Forms$NoAction. mx check is 0 errors
on both engines' output, before and after — this was never a build error, which
is exactly what made it dangerous.

The control pins the fallback separately. A test asserting "SIGN_OUT is no
longer NoAction" would also pass if someone had merely softened the default, so
OPEN_LINK — still unimplemented — is asserted to STILL hit Forms$NoAction on
legacy and still be refused on modelsdk.

OPEN_LINK is left unimplemented deliberately: gen calls it OpenLinkClientAction
and its Address is an element rather than a string, so it is a separate job.
`mxcli syntax page.action` listed it as available with no caveat; it now says
it is written by neither engine and points at a nanoflow instead.

Reported as CapTrackV2 FINDINGS §10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`ACTIONBUTTON … (Action: OPEN_LINK 'https://…')` reached storage on neither
engine. modelsdk refused it; legacy fell through to its QUIET default and wrote
Forms$NoAction, so the button rendered, said "Docs", and did nothing — with
`mxcli check`, `exec` and `mx check` all clean. Same defect as the SIGN_OUT
case in the previous commit, and the syntax help listed the action as available
either way.

Two traps here that a Studio Pro reference settled and reasoning would not.

The STORAGE NAME is Forms$OpenLinkClientAction. The semantic type is
LinkClientAction and the executor stamped "Forms$LinkClientAction", which is
not what Mendix stores — a wrong $Type that never reached disk only because
nothing could write the action at all. And the address is not a string
property: it is a nested Forms$StaticOrDynamicString.

Pinned against 31 Studio Pro-authored link buttons (ako/TestApp,
FeedbackModule) — exactly five keys, LinkType "Web" in all 31:

    { "$Type": "Forms$OpenLinkClientAction",
      "Address": { "$Type": "Forms$StaticOrDynamicString",
                   "AttributeRef": null, "IsDynamic": false,
                   "Value": "https://www.mendix.com/" },
      "DisabledDuringExecution": true,
      "LinkType": "Web" }

6 of those 31 are DYNAMIC — the address is read from an attribute at runtime.
MDL cannot author that, so DESCRIBE flags such a button rather than printing
its address as a literal, which would round-trip into a different link.

gen declares a fourth property on Forms$StaticOrDynamicString, `Attribute`,
that not one of the 31 documents carries. It is deliberately left unset:
writing a key Mendix does not store is what produces a document mxbuild accepts
and Studio Pro cannot open.

The previous commit's "still unimplemented" controls named LinkClientAction,
which stops being a valid control the moment this lands — they now name
ShowHomePageClientAction, which has no gen type, no metamodel counterpart and
no MDL statement that builds one, so it is structurally unwritable rather than
merely not yet written.

Both engines emit the same document; mx check 0 errors on each.

Reported as CapTrackV2 FINDINGS §10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
A navigation menu can carry a log-out item. mxcli could neither author one nor
read one back:

  authoring  MDL's `menu item` took PAGE or MICROFLOW only, so there was no
             spelling for it at all.
  reading    ako/TestApp's sign-out menu item came back as a plain
             `menu item 'Item 5';`, so DESCRIBE -> exec turned a working log-out
             entry into a dead one — silently, with mx check clean.

Fixing the sign-out BUTTON proved nothing about this, because a menu item's
action reaches storage through four switches that share no code with the button
path — two writers (a standalone menu document and the menu inside a navigation
profile) and two readers. All four had to be wired:

    menuActionToGen      NoAction default        modelsdk, menu document
    navMenuAction        NoAction default        raw BSON, navigation profile
    resolveMenuAction    raw type name           modelsdk read
    parseNavMenuItem     raw type name           legacy read

The readers are the subtler half. Both had a fallback that stored the unmapped
$Type, which LOOKS like it preserves information — ActionType became
"Forms$SignOutClientAction" — while breaking the round trip, because DESCRIBE
and both writers key on "SignOutAction". A round trip closes only when the
reader produces the exact string the writer consumes.

Studio Pro stores the same element a button carries — Forms$SignOutClientAction,
DisabledDuringExecution true, nothing else — which is why SIGN_OUT sits beside
PAGE and MICROFLOW rather than getting a syntax of its own. It names no target,
so the visitor reads it separately from the PAGE/MICROFLOW switch; folding it in
would consume a qualifiedName and mis-assign a trailing ICON. That case is in
the example.

Measured on ako/TestApp (Mendix 11.14), and controlled by neutralising both
readers and re-reading it:

    before   show navigation menu Responsive  ->  Item 5
    after    show navigation menu Responsive  ->  Item 5 -> sign out

describe -> exec now puts an identical Forms$SignOutClientAction back on disk,
and mx check reports 0 errors on the result.

Reported as CapTrackV2 FINDINGS §10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
The store describes project.md as loaded every session, and justifies giving it
the tightest cap in the store on exactly that basis. Nothing made it true. The
only route to the brain in a generated project was a row in CLAUDE.md's skills
table, and the skill's own description is symptom-triggered ("use before
designing something that looks like it was decided before") — so a session that
never hits the symptom never learns the project's decisions, and the cap was
resting on a load that did not happen.

The generated CLAUDE.md now names docs/brain/project.md as the first thing to
read, with the module shards on demand and `brain plan` for picking work up.
That is the mechanism; the skill remains the detail.

Asserted by a test rather than left to review, including that the section sits
in the first third of the file — "read this first" is otherwise a claim the
document's own ordering contradicts. Control: removing the block fails the test
with the two paths named.

Noticed by comparing the design against Anthropic's AI-native SDLC playbook,
which is explicit that CLAUDE.md is what gets read at the start of a session.
The gap was ours: we had the policy without the mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An @expect that is syntactically valid but only rejected by MxBuild took
down an entire `mxcli test --local` run: no test results at all, valid
tests in the same file never executed, and the cause arrived as ~200
lines of mxbuild JSON with the real error among dozens of unrelated
Atlas warnings.

BuildResult parsed only status and message and left the rest of the
response unread, though mxbuild returns every problem with a severity, an
error code and a location. Measured on 11.13, a failing build returns 18
problems of which one is the error, so printing the body meant 11,580
bytes in which nothing marked the line that mattered. Filtering to errors
renders it as:

  [CE0117] Error(s) in expression.
    -- at MxTest / Microflow 'Test_test_3' / Decision '$result = 3'

The location's document names the generated test microflow, so it maps
back to the test exactly: that test is reported ERROR with the
consistency message and every other test as SKIP -- never PASS, because
nothing ran. An error in the project rather than the suite is reported as
such instead of being blamed on a test.

The finding's other suggested remedy -- refusing an unbound variable at
injection time rather than letting the build find it -- was implemented
and then removed. It passed check-mdl's 465 scripts and the whole unit
suite and was still wrong: `mxcli test` execs its microflows, and the
microflow validator's scope model tracks variables where they are
ASSIGNED. Reusing it to check READS refuses valid work. Two independent
holes, both surfaced only by `make test-integration`:

  - $latestHttpResponse is a Mendix system variable that no MDL statement
    declares; it is populated after SEND REST REQUEST
  - a loop iterator is registered only when the list's type is known

Both refuse a microflow `mx check` accepts at 0 errors. A variable model
built for checking writes only has to know the names being bound; the
read side has to know every name that can legally be in scope, including
ones the platform supplies. That set cannot be enumerated confidently
here, and each miss refuses a working microflow -- so the build stays the
authority and this change makes its verdict legible instead.

Nothing is lost by dropping it: CE0109 reaches the build and is
attributed to its test by the same path as CE0117.

Controls: an error in a generated test microflow produces per-test rows,
an error in the user's own model produces none, and no test may be
reported PASS after a failed build. The response shape is captured from a
real failing build rather than inferred -- no fixture here had recorded
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Two gaps, both surfaced by comparing the design against Anthropic's AI-native
SDLC playbook.

OPEN QUESTIONS. The store recorded what had been decided and what was going to
be built, but not what was still undecided. A known unknown is brain-shaped by
the store's own test — not derivable from the model, lost when the conversation
ends, and expensive to rediscover — so it had nowhere to go and was simply
forgotten.

A question is a decision that has not been made yet, and it needs its own
treatment for one reason: its anchors must NOT be checked. It routinely names
something that does not exist, because the question is often precisely whether
it should. Measured, with the identical anchor: exit 1 as a decision, exit 0 as
a question. That is the same property that separates requirements from
decisions — what a failed anchor MEANS — so this is a third point on an axis
the store already had, not a new mechanism.

Questions live beside the decisions they will join rather than in a file of
their own, because the moment you need to see one is while reading what that
module already decided. The OPEN marker therefore travels in the entry rather
than being implied by the file, as a requirement's kind is. That is still one
copy of the fact: what the store forbids is two.

`brain resolve` converts a question into a decision IN PLACE, keeping its id and
its position — an answered question is the same piece of knowledge as the
question — with the question retained as the answer's context. From that moment
its anchors are checked like any other decision, which is the transition the
kind exists for and is asserted rather than assumed.

A question filed against a slice is counted apart from its requirements: an
unanswered question is not outstanding scope, and counting it as such would
overstate what is left to do.

A CAPTURE TRIGGER. Capture had no doctrine for WHEN, which left the two halves
lopsided: the plan fills at bootstrap, while decisions fill only if someone
remembers. The skill now names the trigger the playbook uses for CLAUDE.md — a
correction you have had to make twice, because the second one predicts a third
for someone else — plus a choice between real alternatives where the losing one
would look reasonable later.

`mxcli lint` now reports unanswered questions alongside unpromoted entries.
Same reasoning as before: a question nobody answers is the one kind of entry
that gets more expensive the longer it sits, and a report only `brain check`
prints is a report nothing demands.

Controls in both directions throughout, including that `--open` does not quietly
disable the check for everything, that an entry written before the marker
existed still reads as a settled decision, and that a resolved question stays
where it was rather than moving to the end of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es them

The proposal described one kind of record, a decision, while the store has
three. Adds §4.5 with amendments A10 and A11.

The separating property is not three separate arguments but one: what a FAILED
anchor means. A decision's anchor points backward at what exists, so one that
stops resolving means the decision is stale. A requirement's points forward, so
one that does not resolve means not built yet. A question's points at what is
under discussion, so it means nothing at all — the question is often whether the
thing should exist. Identical syntax; only the direction differs, and that is
the whole lifecycle.

Records that this was measured before it was designed: as an ordinary entry, a
single not-yet-built requirement takes `brain check` to exit 1, and so does a
question. Neither could be more decisions without making the check useless.

Also records where this departs from the AI-native SDLC playbook and where it
was behind it. Departs: the playbook keeps a plan.md and recommends a hook
enforcing that the diff still matches it — a self-reported artifact being
policed, which is only necessary when there is no queryable model. Behind: the
playbook's trigger for CLAUDE.md ("when Claude makes a mistake twice, the
correction goes into CLAUDE.md") is the one thing this design was missing
outright, and is now A11.

And notes the defect the comparison exposed, because the lesson generalises: the
proposal asserted project.md was loaded every session and justified the store's
tightest cap on that basis, with nothing behind it — a generated project
mentioned the brain once, in a skills-table row, behind a symptom-triggered
description. A policy about what is loaded is worth nothing without the thing
that loads it, and the same question should be asked of any future claim of that
shape here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(test): attribute a failed build to the test that caused it
Sign-out and open-link actions: stop dropping them to Forms$NoAction
docs(bootstrap): prefer the environment's cached Mendix version, else the newest
The nightly is red on 10.24 and 11.6.8 while 11.12, 11.13 and 11.14 are
green. Four of the 29 mendixlabs#1018 documentation fixtures cover doctypes that do not
exist below 11.9 — ai model, knowledge base, consumed mcp service, agent —
and had no version gate, so on an older project they failed at CREATE:

    --- FAIL: TestDocumentation_SurvivesRewrite/ai_model
        create: create model requires Mendix 11.9.0+ (project is 11.6.8)

That failure says nothing about documentation carry. The doctype is simply
absent, which is what a gate is for.

Reproduced locally rather than reasoned about: mxbuild 11.6.0 is already
cached, and MX_BINARY pins it, giving the identical message and line in four
seconds. Both controls run:

  11.6.0   4 SKIP, 25 run and pass, both control tests pass
  11.13.0  no skips — all four actually run and pass

The second one is the one that matters. A gate that always skipped would turn
the whole matrix green while testing nothing, and nothing else would notice.

The minimum is carried as a version on the case rather than a boolean, so the
reason is legible where the case is written and it mirrors the registry entries
in sdk/versions/mendix-11.yaml (agent_model, agent_knowledge_base,
agent_consumed_mcp_service, agent — all min_version 11.9.0).

Every other doctype passes on 10.24 and 11.6.8 untouched, which is a useful
incidental result: the documentation carry itself holds across all three
supported majors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the conflict GitHub reports on the findings shard. The shard is
merge=union in .gitattributes, so both appended lines are kept — but GitHub's
server-side merge does not run merge drivers, which is why it shows a conflict
the local merge does not have. Merging here does the union locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
claude and others added 4 commits September 4, 2026 12:33
A generated domain model opened in Studio Pro as a single horizontal line of
entities with the boxes touching — unreadable at any zoom. Reported against a
40-entity model (ako/CapTrackV2, Mendix 11.13).

The default position for a CREATE ENTITY with no @position was:

    location = model.Point{X: 100 + len(dm.Entities)*150, Y: 100}

Same y for every entity ever created. 40 entities is a 6,950px row, and 150px
is narrower than an entity box, so they overlapped as well.

Two changes, because there are two different questions.

**The default** (mdl/dmlayout.GridSlot) is now a wrapping grid. It cannot be
better than that: the first entity of a script is placed before the last one
exists, so no create-time rule can see the association graph. Slot n stays a
function of n alone, so adding an entity never moves one already written. Those
same 40 entities now span ~1,400px.

**mxcli layout** does the real thing, from the whole model once it exists.
Entities are layered on the association graph — an entity referencing nothing
else in the module is a lookup and goes left, everything else one column past
the furthest thing it references — so association lines mostly run one way
instead of crossing the diagram. Entities with no association at all go in a
band underneath rather than among the lookups, which is where the
non-persistent helpers belong.

Run against CapTrack's own 01-domain.mdl, the layering falls out of the model:
six lookups in one column, then Team/GoalBucket, then PlanScope's band, out to
EmployeeMonth/Movement. mx check 0 errors, and the positions round-trip into
MDL as @position.

It is opt-in and it overwrites hand-arranged positions in the modules it
touches, so --dry-run lists the moves first. Marketplace modules and System are
skipped, and NAMING one is an error rather than a silent skip — a silent skip
reports success having done nothing.

Three properties the tests pin, each measured rather than assumed:

  - idempotent: a second run detects nothing to move and never calls the
    writer (16 of 16 moved, then "already laid out").
  - local: adding one entity with one association moved 3 of 17, not the model.
    A layout that reshuffled everything would make each domain-model commit an
    unreadable diff.
  - deterministic: an unsorted walk gives a different diagram every run, which
    rewrites the unit every time — the churn ADR-0008 exists to prevent. The
    control fails on run 0.

Two constraints that shaped the geometry: an entity stores only Location and NO
Size (Studio Pro derives the box when it draws), so spacing is estimated from
name length and attribute count; and a Mendix position is the box's CENTRE, so
placement adds half a box rather than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
fix(executor): version-gate the four doctypes that need Mendix 11.9
The layout change landed with CLAUDE.md and the Cobra help updated and nothing
else. The three that were missed are the user-facing ones.

`mxcli syntax domain-model.entity.alter` did not document SET POSITION AT ALL —
a gap that predates this work — so the topic now lists it, says the coordinate
is the box's CENTRE rather than its top-left, and points at `mxcli layout` for
arranging a whole module.

The generate-domain-model skill was actively wrong:

    IMPORTANT: All entities MUST have @position annotation
    Without it, entities appear at (0,0) or random locations.

Neither half held. An entity without a position took the next slot in a
deterministic row — now a grid — and never (0,0). The "MUST" was advice nothing
enforced, and the model that prompted this work ignored it, which is part of how
it came out as a 6,000px line. The section now says positions are optional, that
a grid is a default rather than a layout, and that a generated domain model is
better served by writing none and running `mxcli layout` afterwards.

New docs-site page tools/domain-model-layout.md, linked from SUMMARY.md: what
the layering does, the measured column breakdown, the flags, the fact that it
replaces hand-placed positions, and the idempotence/locality properties that
make it safe to leave in a build script.

make check-skill-mdl passes (205 blocks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Lay domain-model entities out instead of stacking them in one row
@ako
ako merged commit 41c55d0 into mendixlabs:main Sep 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants