Skip to content

Sync ako/mxcli: rewrites stop deleting what the statement did not restate, authorable message definitions, and a project knowledge store - #1022

Merged
ako merged 64 commits into
mendixlabs:mainfrom
ako:main
Sep 3, 2026
Merged

Conversation

@ako

@ako ako commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Thirty-nine commits since the last sync. Two themes dominate: a new project
knowledge store
, and a run of fixes to things that a rewrite silently
deleted — documentation, mapping samples, a SOAP binding.

A rewrite no longer deletes what the statement did not restate

Four separate cases, all the same shape: create or replace rebuilds a document
from the statement, so anything the statement does not mention is gone. None of
them is a build error, which is why they survived.

  • Documentation (CREATE OR REPLACE / CREATE OR MODIFY deletes an object's doc comment (Documentation), silently #1018). create or modify set Documentation from the
    statement unconditionally, so a statement with no /** … */ wrote the zero
    value over the stored one. Not a drop — an empty overwrite. Absent and
    empty are different facts and only the parser knows which, so
    findDocCommentText now distinguishes them. Carried across a full pass of
    all 29 doctypes.
  • A mapping's OriginalValue — the sample parsed from the JSON structure's
    snippet. The old behaviour rested on a measurement over two mappings a blank
    app ships; at corpus scale 2,322 of 3,042 elements carry one. But neither
    default is right: the split is per document (145 mappings all, 107 none, 2
    mixed), so a rewrite carries the stored value forward instead of choosing. The
    original decision stands for newly authored mappings.
  • A SOAP mapping's web-service binding. A mapping has four schema sources,
    not three; the fourth was absent from the semantic type, so every rewrite
    destroyed it (CE6896 + CE0270). Now refused rather than rewritten —
    carrying it would claim the rest of the document survives too, which mxcli
    cannot check.
  • A JSON structure's snippet formatting. describe pretty-prints, so
    describeexec reformatted a one-line snippet. Now preserved when the JSON
    is semantically equal.

Message definitions are authorable

The last of a mapping's four schema sources that a script could create — 74 of
327 mappings in the demo corpus (22.6%). Unlike an XML schema or a WSDL it holds
nothing external: it is a selection over the domain model.

create message definition collection Sales.MD_Order (
  definition OrderMessage for Sales.Order as 'Orders' (
    OrderId,
    Sales.OrderLine_Order/Sales.OrderLine as 'Lines' ( Sku ),
    Sales.Order_Customer/Sales.Customer ( FirstName )
  )
);

ako and others added 30 commits September 1, 2026 15:42
…opping it

A mapping has FOUR source kinds, not three. Beside a JSON structure, an XML
schema and a message definition, it can be sourced from an imported web
service — a WSDL binding: ImportedWebService plus ServiceName, OperationName
and RootElementName (and ParameterName / IsHeader on an export mapping).

model.ImportMapping carried three and a comment saying "Schema source (at most
one is set)", so neither engine read the fourth and every CREATE OR
REPLACE/MODIFY destroyed it. Measured on mxbuild 11.13.0: describe emitted the
mapping with no source clause at all, and describe -> exec — which is how a
document is copied — left ServiceName and OperationName blank and removed
ImportedWebService and RootElementName outright:

    [error] [CE6896] "A mapping must have exactly one schema source."
    [error] [CE0270] "No root element could be found in the schema."

So a working SOAP integration became an unbuildable one, and the diff blamed
the statement the user ran rather than the source they never mentioned. Same
class as the queued-call guard (ADR-0005), and worse than #259's dangling
reference, which wrote a bad name through rather than deleting a good one.

The binding is now read on both engines and the rewrite REFUSED, naming the
service and operation that would have been lost. Refusing rather than
preserving is deliberate: carrying the binding through a rebuild would claim
the rest of the document survives too, and mxcli cannot check that — a SOAP
mapping's elements resolve against the WSDL's schema entries, which live inline
on WebServices$ImportedWebService and which mxcli does not read. describe marks
the source as NOT REPRESENTABLE rather than emitting nothing, because the
silent output parses and re-executing it is the deletion.

Verified against planted import and export mappings on both engines: refused,
binding intact afterwards, ordinary JSON-sourced mappings still rewrite.

Closes #365

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

A Microflows$MicroflowParameter is a stored node with real geometry
(RelativeMiddlePoint + Size 30;30) that Studio Pro lets you drag, but no
MDL annotation reached it. Two consequences, the second the more serious:
a generated flow's parameter block landed wherever the writer put it, and
an existing hand-aligned one was MOVED by any rewrite — including a
describe -> exec of mxcli's own output. Measured on a real nanoflow, a
parameter at -77;0 came back at 200;53; on a 1971-unit project, 20 of 28
parameters sat off the derived grid and so were moved.

The cause was a four-point chain: the semantic type had no position
field, so neither reader carried one, so both writers could only
recompute 200+idx*100;53 inline, and the grammar had no slot to state one.

The placement rule is @start's, unchanged (mendixlabs#884, mendixlabs#951): a parameter
sitting exactly where the layout would have put it is mxcli's own
arithmetic handed back, carries no intent, and is re-derived; one
anywhere else was placed by a person, survives a rewrite, and is emitted
by DESCRIBE. Carrying stored coordinates over unconditionally is the trap
that rule exists to avoid — inserting a parameter would strand the
existing ones on the old grid while the new one landed on top. The
arbitration lives in the readers, so a non-nil Position means intent
everywhere downstream; it is a pointer because 0;0 is a coordinate a
person can choose.

Syntax needed one grammar line (annotation* on microflowParameter), with
no ANTLR ambiguity, so the block-level @parameters(...) alternative is
unnecessary. @position is the only annotation a parameter takes; anything
else — a typo of it above all — is refused as MDL059 rather than parsing
and doing nothing, in check, exec and the LSP.

Covers microflows, nanoflows and rules (shared parameter grammar) on both
engines. The four near-duplicate describers now share one helper, so the
annotation cannot appear from one command and not another.

Verified on mxbuild 11.13: authored positions stored verbatim, the
unannotated control still deriving to 200;53/300;53, describe -> exec
reporting Unchanged, 0 errors. Control: with the read-side carry stubbed,
-77;0 -> 200;53 returns and DESCRIBE emits nothing.

Refs: ako/mxcli#993

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
A mapping binds to one of four schema sources and MDL can create exactly one.
Measured across all 327 import/export mappings in the nine demo apps: JSON
structure 250 (76.5%), message definition 74 (22.6%), XML schema 3 (0.9%),
imported web service 0. So a project built entirely through MDL can express
three-quarters of the mappings a real app has.

Message definitions are the one remaining source that is both worth doing and
doable: unlike an XML schema or a WSDL, the document holds nothing external —
it is a selection over the domain model.

The spec is measurement-first, over all 36 collections / 56 definitions / 4,686
elements. Almost every property is a constant or is derived; the author chooses
only the collection name, each definition's name, its root entity, the members,
and an optional rename.

Two findings shape the syntax:

MaxOccurs is NOT a function of the association's type — all 927 resolvable ones
are Reference, yet 526 are MaxOccurs=1 and 401 are -1. It tracks the DIRECTION
of traversal, with zero counter-examples: holder is the FROM entity → 1 (496),
holder is the TO entity → -1 (401). Getting that backwards exposes a list as a
single object with no build error behind it, so the statement names the target
entity (`Assoc/Module.Entity`, the shape mappings already use) and direction is
explicit rather than inferred.

Studio Pro pluralises a repeating element's ExposedName (Reference→References,
Factory→Factories, Parts→Parts) while keeping ExposedItemName at the singular —
461/461. ExposedItemName is therefore free; the plural is not, and the proposal
recommends NOT implementing English inflection, defaulting to OriginalName with
`as` to override. Same conclusion as #272's item naming, and the cost of it is
stated rather than buried.

Closes nothing; opens the design for review.

Refs #272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving the findings shard by hand, because `merge=union` got it wrong.

main did not append to `findings/mdl-executor.jsonl` — it REWROTE it, deleting
250 records that the bug-pattern consolidation had folded into
docs-wiki/bug-patterns/. Union never deletes, so it resurrected all 250: the
merged tree had 1,135 records where main has 884.

Resolved as main's shard verbatim plus this branch's one new record (885).
Verified: the 250 exist nowhere else on main's shards, so they were deleted
rather than re-sharded, and none of them survives here.

Worth noting for the next person: union is right for concurrent appends to a
file of independent records — which is what .gitattributes documents — and
wrong the moment one side deletes or rewrites. A silently inflated file is the
failure mode, and `make check-findings` counts records without noticing they
came back from the dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(mappings): refuse to rewrite a SOAP-sourced mapping instead of dropping it
feat(microflow): @position on a flow parameter, and stop moving hand-placed ones
docs(proposal): spec authorable message definitions
…requires it

The first draft had only collection-level CREATE/DROP/DESCRIBE/SHOW, on an
unstated assumption that a definition is a flat field list. Measuring the
corpus shows it is not: definitions nest to depth 7, with most elements at
depths 5-7 (2,208 at depth 7 alone), a mean of 4.7 members per object element
and a tail out to 22.

So a whole-document CREATE OR MODIFY is a poor tool for "expose one more
attribute" — restating a seven-level document to add a leaf is exactly the
diff-unfriendliness ADR-0003 argues against, and is why ALTER ENTITY ADD
ATTRIBUTE exists rather than only CREATE OR MODIFY ENTITY.

Adds two statement families:

  alter message definition collection M.MD_Order add|drop|rename definition ...
  alter message definition M.MD_Order.Order add|drop|set member ...

Three choices worth reviewing. The definition is addressed as
Module.Collection.Definition, the three-part reference WITH MESSAGE DEFINITION
already takes, so the two cannot drift apart. It is SET member ... AS, not
RENAME ... TO, because ALTER ENTITY's RENAME changes the model and rewrites
every reference while this changes only the element's ExposedName — borrowing
the verb would promise something far larger. And a nested member is reached
with `in <exposed-name path>` rather than a /-joined name, because / already
means "association to entity" inside a member.

Also raises a wildcard member (`( * )`) as an open question rather than
designing it: the checkbox-tree UI makes it a natural thing to want, but
nothing in the corpus says anyone does, and proposing it would be designing for
a shape with no document behind it.

Verification plan gains the control that separates a targeted edit from a
rebuild: add one member and assert every other element is byte-identical.

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

`mxcli diff` against the UNMODIFIED output of `mxcli describe` reported
that the script would delete activities the executor round-trips exactly:
every java-action call, `download file`, `show message` and every
@position/@start/@Curve line rendered as a deletion with no counterpart.
Retrieve constraints came back as a Go struct pointer
(`where &{0x236139b542d0 index=0}`), and an entity dump read as modified
purely on `create PERSISTENT entity` vs `create persistent entity`.

One cause, not four: diff rendered its two sides with two different
renderers. The project side went through the real describer
(renderMicroflowMDL, already shared with diff-local); the script side went
through a second AST-to-MDL renderer whose statement switch covered 18 of
43 activity types with NO default case, so an unhandled activity emitted
zero lines silently. Its expression switch ended in `%v` on an interface
holding a struct pointer, which is the pointer leak.

The fix is structural. The build phase of the CREATE handlers is split out
(buildMicroflowFromStmt / buildNanoflowFromStmt) so diff can assemble the
flow the script describes without writing it, then render it through the
same describer the stored side uses. The second renderer is deleted rather
than extended — adding the 25 missing cases would have fixed the symptom
and left the drift that produced it.

Two things the split has to get right, both load-bearing: the build phase
MUTATES (findOrCreateModule and resolveFolder create documents,
consumeDroppedMicroflow consumes session state), so a dry run takes an
AllowCreate=false path with a read-only folder lookup; and the exec-only
refusals are skipped there, because a refusal that aborts the build would
leave the user with no diff at all rather than a diff plus the warning
exec gives anyway.

Separately, diffStatement skipped every statement it could not compare
without a word — 6 of 158 statement types are supported — so a script that
would genuinely add a constant summarised as "0 new, 0 modified, 0
unchanged". That is worse than a wrong count, because nothing is on screen
to disbelieve. Unsupported statements are now listed under "Not compared",
and a comparison that fails is reported instead of dropped.

Verified on mxbuild 11.13: an unmodified describe dump of a microflow, a
nanoflow and an entity all diff as unchanged, while a one-word edit still
reports exactly one line and removing the java action still reports a
deletion. exec is unaffected (0 errors, still idempotent). Control:
re-instating the drop of unrendered activities brings the false deletion
straight back.

mendixlabs#913's split-indentation test drove the deleted renderer and is retargeted
at formatMicroflowActivities, where that rule now lives.

Refs: ako/mxcli#997

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
Response to mendixlabs#1017, which asks for a proposal and for its
four stated assumptions to be checked against the codebase rather than
trusted. All four needed amending and one is wrong outright.

- Documentation via MDL: available for domain-model objects via doc
  comments and ALTER … SET DOCUMENTATION, but uneven — `create … comment`
  was a dead option on seven doctypes and was removed rather than wired.
  Phase 3 now opens with a per-doctype audit.
- The catalog: the objects view unions 43 document types with a
  QualifiedName, and resolution measured at 0.038 ms on a real 382-object
  project, so speed should not shape the design. Two caveats do: the view
  indexes only describable types, so a missing anchor is not proof of
  staleness, and member-level anchors need attributes_data.
- Starlark: rules are discovered from files, so a generated rule is just
  a generated file and no engine work is needed — but `mxcli init` writes
  into the same directory, so generated rules need a reserved prefix.
- Release mechanics: THERE IS NO GORELEASER. Releases run `make release`;
  skills ship by go:embed from cmd/mxcli/skills, mirrored with
  rsync --delete from .claude/skills/mendix.

The proposal also brings evidence from mxcli's own store of this shape —
the bug findings and their wiki digest — because it has already failed in
four ways reachable from the brief as written: it grew past being
readable, its digest went three months without a trigger, append-only
plus merge=union produced 256 silent duplicates that are on main right
now, and claims about the mechanism (including "it runs in CI") went
stale. Those failures argue for the brief's caps and human-in-the-loop
promote, not against the feature.

Three open questions are recorded rather than answered, including that
THEORY.md — which the issue says to read and update — does not exist
anywhere in the repository.

No implementation. The issue asks for review before code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections from review, and a measurement that changes a
conclusion.

**Audience.** The proposal conflated two stores. The bug findings and
their wiki digest are for developing mxcli itself — a Go repo, many
parallel agent sessions, hundreds of entries. This brain is for USERS of
mxcli, in their own Mendix project: one developer and their agent, tens
of lines, reviewed by someone who may never see mxcli's source. Stated
up front now, and §3 is reframed as an analogy with the differences
named rather than as a precedent. Three of its failures transfer
(unbounded growth, an untriggered curation step, stale self-reported
claims); the duplicate-merge one does not, and the earlier draft
over-weighted it.

**Documentation.** `create … comment` was removed BECAUSE javadoc-style
comments exist and reach the .mpr — not because the capability was
missing. Verified: a /** … */ header on a microflow and on an entity
lands in the stored .mxunit and comes back through DESCRIBE. Wired at 28
sites across mdl/visitor. The earlier draft drew the wrong inference
from the removal.

But testing the next question found worse: a rewrite DESTROYS it.

                       microflow doc      entity doc
  after create           PRESENT            PRESENT
  after replace mf       ABSENT             PRESENT   <- control holds
  after modify entity    ABSENT             ABSENT

Each rewrite destroys its own object's documentation and leaves the
other alone, and mx check is clean throughout because a document with no
documentation is valid. So a statement that says nothing about
documentation silently deletes whatever was promoted there. Tier 1 —
the strongest idea in the brief — is blocked until mxcli's writers
preserve documentation they do not restate, which is a writer fix and
belongs in its own issue.

The first version of that test chained `&& echo SURVIVED` to `head -1`,
which exits 0 on empty input, and reported success regardless of what
grep found. Recorded in the proposal, because it is the same shape as
the failures §3 catalogues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Filed as mendixlabs#1018. Also records the second control found
while writing the repro: ALTER ENTITY … ADD ATTRIBUTE preserves the doc
comment, which localises the defect to the rewrite paths and gives users
a workaround today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(proposal): project brain, with the brief's assumptions verified
fix(diff): render both sides through one describer, and say what is not compared
docs(proposal): add targeted ALTER for message definitions, and the nesting measurement that requires it
This gate built ZERO image widgets, so it ran green through both image
defects fixed in 597db1b -- the CE0463 on every image widget mxcli wrote,
and the image reference that could not be authored at all. The file said
"no pluggable replacement exists" and skipped images entirely, which was
wrong: `image` routes to com.mendix.widget.web.image.Image. Only the
BUILT-IN staticimage/dynamicimage are deprecated (CE0582) and those stay
out. bug-tests/widget-image-collection-entry.mdl documents the fix, but
bug-tests/ is not what CI runs -- TestMxCheck_DoctypeScripts reads only
doctype-tests/.

Three variants, chosen so the coverage is not a smoke test:
  - pixels/36, where width is a VISIBLE property
  - default units, where width and height are HIDDEN -- the case that
    regressed, since a hidden property must be written with its declared
    default rather than skipped onto whatever the template captured
  - imageUrl mode, whose source is a text template rather than the Image
    field

Non-vacuity measured against the fix that is actually in the tree, not a
reconstruction of it: with the declared-default write in
hiddenUnnamedProperties reverted to a plain skip, this script reports
CE0463 on all three widgets; restored, 0 errors. Omitting the `Image:`
line likewise turns mx check red with "No image selected.".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEZmExJUvn2nWTWE9mrd4i
The user manual was the one artifact the authorable-layouts arc never
updated, and it was wrong rather than merely thin.

create-layout.md documented `CREATE LAYOUT module.Name { widget_tree }`
-- no header at all. Run verbatim that is an error, not a shortcut:
"layout needs a layouttype". It omitted `class`, which is load-bearing
(Atlas scopes its layout rules to .layout-atlas, so a layout without one
builds clean, passes mx check and renders with no topbar bar and no
sidebar rail), omitted OR REPLACE, the scrollcontainer/region/placeholder/
navigationtree vocabulary and the Marketplace refusal, and told the reader
"for advanced layout customization, use Mendix Studio Pro" -- for
something mxcli has done since #304. Neither of its two "Examples" was a
CREATE LAYOUT; both were CREATE PAGE statements naming an Atlas layout.

Rewritten from measurement: every statement in both pages was executed
against a real 11.13 project and every quoted error message was produced
by running the case that raises it, including the marketplace refusal, the
unknown-header-property error, the no-placeholder refusal and the
placeholder guard with its MAP remedy. The resulting project is 0 errors.

alter-layout.md is new: ALTER LAYOUT had no page on the site at all, nor
did the bulk `ALTER PAGES ... SET LAYOUT` migration form. It covers
addressing a region by slot, why ALTER beats CREATE OR REPLACE for a
layout you did not write, and the guard's refusal semantics. alter-page.md
already documented the single-page SET Layout, so that section gains only
the guard note and a pointer.

Also: `layout` is a top-level syntax path but was missing from the
hand-maintained topic list in help.go, so `mxcli syntax layout` worked
only if you already knew the word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEZmExJUvn2nWTWE9mrd4i
0dd1e01 corrected all three navigation writers to store the fallback page
as Navigation$NotFoundHomePage, but added a regression test for only one
of them (mdl/backend/modelsdk). Reverting either of the other two --
sdk/mpr/writer_navigation.go, which is the DEFAULT engine's writer and the
one that produced the unopenable project in the report, or
modelsdk/mpr/nav_patch.go -- left the entire suite green, so the defect
could be reintroduced on the default path undetected.

Measured on Mendix 11.13 (Linux) against a blank app, with controls:

  untouched app        0 errors, exit 0
  pre-fix write        StorageLoadException, exit 1, no error count
  post-fix write       0 errors, exit 0
  fix reverted         symptom returns verbatim

Mendix names the cause itself: "Object of type
'Mendix.Modeler.WebUI.Navigation.HomePage' cannot be converted to type
'Mendix.Modeler.WebUI.Navigation.NotFoundHomePage'".

Each new test is verified to FAIL against a reverted writer, naming the
stored $Type, rather than only passing against fixed code.

Two guards accompany the pin. HomePage must keep Navigation$HomePage: the
slots are adjacent and differ only in $Type, so a rename applied one sed
too wide converts the home page as well. And an unset fallback page must
stay an explicit null rather than an element with a blank Page, which
would be a dangling reference where absent is the modelled default.

Refs mendixlabs#1000

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

An icon naming an image collection instead of an icon collection passed
`mxcli check --references`, executed cleanly, and surfaced only at build:

  [error] [CE1613] "The selected custom icon
  'Mod.Icons_SVG.cmdFilter24' no longer exists." at Action button 'abBad'

The reported case -- a button in a page body -- was already caught, and
the resolver shipped in 0.18.0 while the report is against 0.19.0. This is
not a resolver bug but a coverage gap: iconRefsInStatement walked only
CreatePageStmtV3.Widgets, AlterPageStmt operations and
AlterNavigationStmt, so four icon-bearing shapes were never inspected at
all, each holding its widgets in a field the walk did not visit:

  placeholder X { … }   CreatePageStmtV3.Placeholders, held apart from
                        the bare body in .Widgets
  CREATE SNIPPET        CreateSnippetStmtV3, absent from the type switch
  CREATE LAYOUT         CreateLayoutStmt, absent from the type switch
  CREATE MENU           CreateMenuStmt, whose NavMenuItemDef items carry
                        icons exactly as a profile menu's do

Layouts are the costliest of the four: a layout's topbar is shared, so one
wrong icon there is an error on every page using the layout.

Measured on Mendix 11.13. Before, each of the four was silent at exit 0
and produced its own CE1613 from mx check; after, all four are caught at
check time. The positive control is what makes the refusal trustworthy and
ships with the fixture rather than beside it: the same four shapes with a
VALID icon still pass --references, exec, and build to 0 errors. An
over-eager walk would have traded a silent miss for a false refusal.

The unit tests are at the walker, which is where the omission lives, and
each was confirmed to fail before the change.

Refs mendixlabs#1008

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`FOR` binds a Mendix user role, which is project-level and written as a
bare name. Nothing resolved it: cmd_navigation.go turned the AST value
into a string and all three navigation writers dropped it into the BSON
`UserRole` key unexamined.

What that produced depended on the shape of the value. Measured on a
blank 11.13 app:

  for Administrator                → mx check exit 0, 0 errors
  for Supervisor (bare, unknown)   → CE1613, an ordinary build error
  for MyFirstModule.Administrator  → the project will not LOAD:
      StorageLoadException: ... 'MyFirstModule.Administrator' is not a
      valid UserRoleIdentifier

The third is a tier worse than a build error — it is raised before any
checking runs, so there is no error code and no location — and it was
the form mxcli's own documentation recommended, in ten places including
one runnable example. A module role is module-scoped and shares the
name: a blank app has a user role Administrator and module roles called
Administrator in three modules, so the wrong one reads as correct.

`check --references` and `exec` now refuse it through the same function,
naming the bare form to write. A bare unknown role and a case mismatch
(Mendix matches exactly, so `for administrator` really is CE1613) get
their own messages, because they need different fixes.

Two controls, both in the tests:

  - a refused exec leaves the .mpr byte-identical, so nothing partial
    lands before the refusal;
  - a script that CREATEs a user role and then uses it still passes.
    collectDefinitions does not track user roles, so a project-only
    lookup would have refused the ordinary way to write one.

Docs corrected across the reference, the language pages, the quick
references, the synced manage-navigation skill and `mxcli syntax` — the
last two being what an LLM reads before writing navigation MDL.

Fixes mendixlabs#1001

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

Eight places recorded that mxbuild accepts either Navigation$HomePage or
Navigation$NotFoundHomePage in a profile's NotFoundHomepage slot, and that
this is why nothing caught the writers emitting the wrong one. Both halves
are false.

Measured on 11.13, against a build of the three writers emitting the old
spelling (verified on disk, not assumed): `mx check` and
`mxbuild --target=deploy` BOTH exit 1 with

  System.ArgumentException: Object of type
  'Mendix.Modeler.WebUI.Navigation.HomePage' cannot be converted to type
  'Mendix.Modeler.WebUI.Navigation.NotFoundHomePage'

The project cannot be loaded, so every check downstream is lost with it.
The shipped writer is the control: same script, same app, 0 errors.

What actually let it through is that nothing had ever built a project with
a fallback page set. The automated mx-check coverage runs doctype-tests/
only, and no script there sets one -- the first that does was added by the
fix itself.

This matters beyond the wording. The claim told the next reader that a
whole class of $Type error is invisible to the build, which is the opposite
of true and would send them looking in the wrong place. It also gave the
wrong reason for the reader accepting both spellings: a pre-fix project
does not build at all, so reading the old $Type is the repair path -- mxcli
parses the BSON directly and is not bound by Mendix's load validation --
not evidence that those documents are fine.

The findings record is corrected in place with a bumped date, per the
convention in findings/README.md, and carries the generalisable form: "the
build tolerates it" needs the same control as the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
mendixlabs#1018. `create or replace` / `create or modify` set
`Documentation: s.Documentation` unconditionally, so a statement with no
`/** … */` comment wrote the zero value OVER the stored one. Not a drop —
an empty overwrite. The run reported success and mx check stayed clean,
because a document with no documentation is valid.

Absent and empty are different facts, and the parser is the only layer
that knows which: findDocCommentText returned "" for both. It now has a
sibling, findDocComment, returning the text AND whether a comment was
written, and the statement carries a DocumentationSet bit. An absent
comment preserves what is stored; an explicitly empty /** */ clears it.

Preserve-always was not an option. There is no ALTER MICROFLOW … SET
DOCUMENTATION — the SET DOCUMENTATION / SET COMMENT clauses exist only in
the domain-model grammar — so an empty comment is the only clearing
spelling a microflow has.

The mechanism already existed. The same handler reads folder, allowed
module roles, Excluded and the toolbox MicroflowActionInfo off the stored
document and carries them; Documentation was never added to that set, so
this is a fifth member of a list of four rather than new machinery.

Scope is 26 statement types — every one with a doc comment and a rewrite
flag — not the two in the report. Three are fixed and covered end to end.
The other 23 are enumerated in a coverage test that reads mdl/ast/*.go and
fails on any type in neither the done nor the pending list, so the
remaining work is a number somebody can read and a new doctype has to make
a decision about documentation rather than inheriting the bug.

Controls, both of which needed a second attempt:

- the bystander test (a second object, untouched, keeps its own
  documentation) separates "rewrites drop documentation" from "writes drop
  documentation";
- stubbing the carry proves the tests detect it — but deleting the block
  failed to COMPILE on unused variables rather than failing the test, so
  the condition is stubbed to `if false` instead. A control that does not
  build is not a control.

The first survival probe chained `&& echo SURVIVED` to `head -1`, which
exits 0 on empty input and reported success regardless of what grep found.
Recorded in the finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s#1018)

Queue, regular expression, scheduled event, nanoflow and rule join
entity, microflow and enumeration: 8 of 29 doctypes now preserve a doc
comment the rewrite did not restate, each covered by the survival table.

A shared carriedDocumentation helper replaces the per-handler
conditional, so the rule is stated once and every call site is greppable.

Three things the survey caught that a mechanical pass would have got
wrong:

- The scheduled-event struct is built by a pure function with no access
  to the stored event, so the carry belongs at the CALL SITE, next to
  the existing Interval/IntervalType carry. Inserting it where the
  struct is built does not compile.
- Java actions hand-roll the doc-comment search with extractDocComment
  rather than calling findDocCommentText, so the mechanical rewrite of
  the shared helper missed them entirely.
- CreatePageStmtV3 and CreateSnippetStmtV3 were invisible to the
  coverage test: its pattern required a name ending in Stmt, so every
  ...StmtV3 type was excluded from a list whose entire job is to be
  complete. Widened, and image collections (which spell the field
  Comment) are now caught too. True scope is 29, not 26.

The rule case needs the modelsdk engine — legacy refuses to author one —
so the table carries a per-case engine flag. Without it the case fails at
its own precondition and says nothing about the defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s#1018)

15 of 29 doctypes now preserve a doc comment the rewrite did not
restate: json structure, image collection, workflow, constant,
association, view entity and business event service join the first
eight. Every one is covered by the survival table.

Two shapes needed more than the standard carry.

Associations already preserved, by accident of an `if doc != ""` guard
on the OR MODIFY path — which also made documentation UNCLEARABLE there.
Routed through carriedDocumentation with a stated() helper that counts
either a doc comment or a COMMENT clause, so preserve and clear now both
work and the rule is the same one everywhere.

Constants keep COMMENT taking precedence over the doc comment, which is
pre-existing behaviour; the change is only that a rewrite mentioning
NEITHER now keeps what is stored.

The test grew a second assertion mode. DESCRIBE does not render an
association's or a view entity's documentation, so a describe-based
check reports a false failure at its own precondition. Those cases read
the stored units instead, which is what the original mendixlabs#1018 measurement
did and is the more faithful question anyway: what is in the model, not
what the reader reports.

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

First slice of #272's implementation (proposal in
docs/11-proposals/PROPOSAL_authorable_message_definitions.md). Parses only —
nothing reaches storage yet.

A message definition is the source for 74 of the 327 mappings in the demo
corpus (22.6%), and was the only one of a mapping's four schema sources a
script could not create. Unlike an XML schema or a WSDL it holds nothing
external: every element names an entity, an attribute or an association.

Statements:

  create [or modify] message definition collection M.Name [folder '...'] ( ... )
  drop | describe message definition collection M.Name
  show message definition collections [in M]
  alter message definition collection M.Name add|drop|rename definition ...
  alter message definition M.Collection.Definition add|drop|set member ...

Two lexer notes, since a new keyword is how scripts break. MEMBER is new (only
MEMBERS existed); ANTLR prefers the longest match so `members` still lexes as
MEMBERS, and MEMBER is added to identifierOrKeyword so an attribute called
`Member` still parses. COLLECTION already accepted singular and plural, so
`show message definition collections` needs no new token.

An association member spells out its target entity (Assoc/Module.Entity, the
shape mappings already use). That is load-bearing rather than decorative: the
stored MaxOccurs tracks the DIRECTION of traversal and not the association's
type — measured, all 927 resolvable associations in the corpus are `Reference`
yet 526 store 1 and 401 store -1 — so naming the target is what makes the
direction explicit instead of something a reader has to work out.

Refs #272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Java action, JavaScript action, menu, OData service and OData external
entity join the carry. 18 of 29 doctypes are now DONE, where done means
carried AND covered by the survival table — nothing weaker.

That definition tightened because it had to. CreateODataServiceStmt has
TWO update paths. The first carry patched one of them, the type sat in
the done list passing every build, and the defect was fully intact. The
test caught it. So JavaScriptAction and ExternalEntity, which are
carried but have no fixture yet, are listed as PENDING rather than done
— an untested carry has now been demonstrated to be worth nothing.

Menus turned out to have a different bug. Their doc comment was parsed
and read by nobody, so the documentation never reached the model at all
— a write gap, not the rewrite gap of mendixlabs#1018, found only because the
survival test failed at its own precondition rather than at its
assertion. One line in visitor_menu.go, and the carry then applies.

Two more paths were the accidental-preserve shape already seen on
associations: an `if x != ""` guard on the OData external entity and the
REST client kept the stored value but made documentation unclearable.
Both now route through carriedDocumentation, so preserve and clear work
the same way everywhere.

Fixture notes worth keeping: java actions resolve their module from an
uncached ListModules and cannot see a module created earlier in the same
harness session, so that case uses a module the copied project already
has; and DESCRIBE renders no documentation for associations, view
entities, business event services or OData services, so those cases
assert against the stored units instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ako and others added 27 commits September 3, 2026 08:08
build(deps): x/crypto v0.55.0 → v0.56.0 for GO-2026-6354/6355
Fourth slice of #272. The feature works end to end.

describe -> exec against ako/TestApp's hand-authored collection reproduces
Studio Pro's document exactly: the executor reports "Unchanged", meaning
canon.Reconcile found the rebuilt content semantically equal and elided the
write. mxbuild 11.14.0 reports 0 errors on a project holding an mxcli-authored
collection and an ALTERed one.

The executor's job is resolution, and two resolutions need the domain model.
Both were wrong first and were caught by the round trip:

  MaxOccurs on an association follows the DIRECTION of traversal, not the
  association's type. Holder is the FROM entity -> 1; holder is the TO entity
  -> -1. An association that connects the two entities in NEITHER direction is
  REFUSED rather than defaulted: a wrong cardinality exposes a list as a single
  object and builds cleanly, so refusing is the only honest answer.

  PrimitiveType is MAPPED, not passed through. Measured across 3,372 exposed
  attributes: Long -> Integer, AutoNumber -> Integer, Enumeration -> String,
  everything else identity. A pass-through gets 279 elements wrong, and the
  round trip caught it on TestApp's ProductId (a Long stored as Integer).

Inherited attributes resolve to the entity that DECLARES them, reusing
DeclaringMemberRef — 398 of 3,697 exposed attributes in the corpus are
inherited, and qualifying one against the entity that merely uses it is CE1613.

`example '...'` is new syntax, added because describe emitting nothing for it
made describe -> exec lossy. Rare (1 of 4,707 elements) but real, and silently
dropping an authored value is the failure this project keeps guarding against.

ALTER edits the stored document rather than rebuilding it, so definitions the
statement does not mention are never round-tripped through the describer.
Dropping or renaming a definition a mapping still references is refused, naming
the mappings.

Refs #272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifth and final slice of #272.

mdl-examples/doctype-tests/40-message-definition-examples.mdl runs clean
end to end and mxbuild reports 0 errors, including an import mapping bound to
the authored definition. It also demonstrates the same association traversed
both ways, which is the property most likely to be got wrong.

Writing it caught one real bug: after a CREATE that resolves a folder, the
cached hierarchy predates that folder, so a later CREATE OR MODIFY did not find
the collection and wrote a DUPLICATE (CE0122). The update branch gets the
invalidation free from applyDocumentFolder; the create branch has to say it.
JSON structures avoid this only because their create path happens to call
applyDocumentFolder first.

The skill section moved to reference/message-definitions.md — the body crossed
the 700-line bound — leaving a pointer with the two facts a reader needs before
they follow it.

The proposal is marked implemented, with two scope decisions corrected against
measurement rather than left as written: inherited attributes are IN (398 of
3,697 exposed attributes, 10.8%), and Example is IN (author-set, rare, and
silently dropped if hardcoded). It also records the five derivations that only
a hand-authored document revealed.

Refs #272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving findings/mdl-executor.jsonl by hand: `merge=union` resurrected a
record main had DELETED.

main removed a 2026-09-01 navigation finding (consolidated into
docs-wiki/bug-patterns/), and union never deletes — so the merged shard had 506
records where main has 504 plus this branch's one. Resolved as main's shard
verbatim plus the new record: 505.

Second time this has bitten (see the merge in #369). Union is right
for concurrent appends to a file of independent records, which is what
.gitattributes documents, and wrong the moment either side deletes or rewrites
— and `make check-findings` counts records without noticing they came back from
the dead, so the check is on line counts against main, not on the tool.

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

A single decisions file and a bare `docs/` were both carried over from the
brief unexamined. Neither survives a 100-module Mendix project.

One file makes the size cap a project-wide budget, so recording a Sales
decision competes with a Finance one and `promote` starts refusing on exactly
the projects that need the store most; and because the store is loaded every
session, an agent working in one module pays context for the other ninety-nine.

Shard on anchor scope instead — `project.md` for cross-cutting, and
`modules/<Module>.md` for anchored entries. The key is derived, not chosen:
an anchor's module prefix is its file name, so there is no index to maintain
(A6 forbids one anyway). Two things become possible that a single file cannot
support: `check` can assert a shard's anchors resolve with the matching
`objects.ModuleName`, and `--changed` can validate only the shards a diff
touches.

Shards are created on demand and the universe is smaller than the module
count — marketplace modules never carry decisions, and `modules.Source`
already distinguishes them. Measured on the sample project: 9 modules, 7 of
them Marketplace, so 2 could ever own a shard. This is not the one-file-per-
record shape that turned the analogous store into 600 files.

Home it at `docs/brain/` rather than `docs/`: a labelled subfolder can be
added to a customer's or Studio Pro's docs tree without collision, and the
folder name is what tells a reviewer the files are brain-managed. That
answers open question 2. Not a root-level `brain/` (a Mendix project root is
already crowded, and discoverability is the skill description's job), and not
an `mxcli-` prefix (that marks files mxcli generates and may clobber — the
opposite of entries a human promotes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proposal recorded documentation loss on rewrite as fatal to tier 1 and
made the fix a precondition of phase 3. That fix has since shipped (#377):
every rewrite path carries the stored documentation when the statement is
silent, and a fixture asserts survival for all 29 rewrite-capable document
types, with an untouched-object control and an empty-comment-clears case.

Leaving the blocked status in a live proposal is the failure §3 warns about —
a self-reported claim that goes stale — so it is corrected rather than left
for the reader to discover.

Also carries forward two testing mistakes from that work, because the brain
will need the same controls: a control that does not compile is not a control
(deleting the carry block failed on unused variables rather than failing the
test), and a type is only done when the carry is both written and covered
(one statement had a second update path no fixture exercised).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gh a rewrite

describe -> exec — how a document is copied — silently dropped a mapping's
OriginalValue (the sample parsed from the JSON structure's snippet) and
reformatted the structure's snippet from one line to multi-line. No build error
either way; pure diff churn against a Studio Pro original.

The old behaviour rested on mendixlabs#882, which measured TWO mappings a blank app ships
and concluded Studio Pro always leaves OriginalValue empty. At corpus scale the
opposite is more common: 2,322 of 3,042 value elements whose structure carries
a sample store it.

But neither global default is right, and measuring the SPLIT is what shows it.
It is per DOCUMENT, not per element:

    145 mappings carry the sample on EVERY element
    107 carry it on NONE
      2 are mixed

So which one a mapping gets is a property of how and when it was authored, not
something mxcli can derive. Always-copy is wrong for 107 mappings; always-empty
is wrong for 145.

A REWRITE does not have to choose — it knows what was stored, so it carries it
(guard-don't-drop, ADR-0005), matching stored to rebuilt by JsonPath because
names and order can change while the schema binding cannot. mendixlabs#882's actual
decision is left intact: a NEWLY authored mapping still writes empty. Its
comment is corrected in place rather than deleted, since the decision stands
and only its measurement was too narrow.

The export side needed the field added to the semantic type and both readers —
its codec writer hardcoded "" rather than carrying it at all.

The snippet half is the same shape: keep the stored formatting when the JSON is
semantically equal, comparing decoded values rather than strings. Anything that
does not parse counts as different, so a malformed snippet is replaced rather
than silently kept.

Verified against ako/TestApp's hand-authored mappings: all three documents are
now IDENTICAL after describe -> exec, and the executor reports Unchanged.
Controls: reverting the carry reports Modified and loses the samples; reverting
the JSON comparison fails the formatting cases.

Closes #379

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(proposal): shard the project brain by module, home it at docs/brain/
CI's build-and-test failed on PR #380:

    --- FAIL: TestMxCheck_DoctypeScripts/40-message-definition-examples.mdl/legacy
        Execution error: failed to create message definition collection:
        creating a message definition collection requires the modelsdk engine

The doctype round trip runs every example through exec + mx check on BOTH
engines, and message definition authoring is modelsdk-only by design — the
legacy writer has no serializer for the document, and building one would
duplicate a shape the codec already gets right, including a typed-array marker
of 2 and an empty-but-present Children list, neither of which is the codec's
default.

engineSkipList is the established mechanism for exactly this; menus, rules,
layouts, navigation profiles and languages all have an entry for the same
reason. This adds the sixth.

Worth recording why local verification missed it: the test is behind
//go:build integration, so `go test ./...` never compiles it. `make vet` now
vets the integration tag set (from the #319 fix), which type-checks but does
not RUN anything. Running it needs mxbuild, which is available locally —
`MX_BINARY=~/.mxcli/mxbuild/11.13.0/modeler/mx go test -tags integration
./mdl/executor/ -run TestMxCheck_DoctypeScripts/<file>` — and that is what
verified this fix: legacy SKIPs with the reason, modelsdk PASSes at 0 errors.

Refs #272

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

A8/A9 changed the storage layout, and the proposal still deferred the CLI
surface and the skill to the brief with "stands as written". Six of the seven
verbs no longer do, and a reader of the proposal cannot see the brief — so
both are restated in full (§4.3, §4.4).

What sharding changes: `init` creates `docs/brain/` with `README.md`,
`project.md` and an empty `modules/`, and refuses a `docs/brain/` it did not
write; `staged` shows the shard each queued entry would land in, so routing is
visible before it happens; `promote` derives the destination from the first
anchor's module, with `--to project` as the escape hatch; `drop` deletes a
shard that becomes empty, so the directory does not accumulate husks reading
as "this module has decisions"; `check` gains `--changed`; `show` reports
per-shard headroom, computed (A6).

What it deliberately does not change: `capture` stays unsharded. Staging is a
queue, not a store, and routing it would force the decision before a human
has looked at the entry.

Two rules the table compresses. The cap is per shard and `promote` is where it
bites, with `project.md` the tightest because it is the only file loaded
unconditionally. And misfiling is a check with one deliberate relaxation: at
least one anchor must resolve in the entry's own shard, while anchors into
other modules are reported rather than failed — "Sales.Order is committed by
Finance.ACT_Post" is genuinely two-module, and forcing it into project.md
would grow the file that must stay small. It is a second axis, not a fourth
anchor state: an anchor can resolve perfectly and still sit in the wrong shard.

A7's host is settled as `mxcli lint`, not `mxcli check`. `check` is scoped to
an MDL script, so a project-level staleness line has no business in its
output; `lint` already takes -p, already runs in review, and is already where
A3's generated rules surface.

The skill gets four instructions sharding makes necessary, the first being the
one that matters: read project.md plus the shards for the modules you are
about to touch, and never the whole directory — an agent that reads every
shard reinstates exactly the cost the sharding removed. The illustrated
frontmatter was checked as real YAML (the folded description round-trips).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements phase 1 of PROPOSAL_project_brain.md: storage, anchors, the seven
verbs and the skill. Opt-in — a project without docs/brain/ hears nothing.

The governing rule is that anything derivable from the model is answered by a
command and never written down. A note that transcribes the model disagrees
with it silently, because nothing checks prose. The store holds only the
negative space: why a pattern was chosen here, which marketplace version broke
what, what a recurring mxbuild error means in this app.

Records shard by anchor scope. An entry's first anchor names its file
(@Sales.Order -> modules/Sales.md); an anchorless entry is cross-cutting
(project.md). Nothing routes it — the module prefix IS the file name, so there
is no index to maintain and none to go stale. That is what makes the size cap
per shard instead of a project-wide budget, and what lets a session load
project.md plus the modules it is touching.

check answers two independent questions, and conflating them was the trap.
Each anchor is resolved / not found / not indexable, and only the middle one
fails: the catalog's objects view covers the describable types, so a scheduled
event would otherwise read as missing and check would demand edits to entries
that are perfectly current. FindDocumentUnit separates the two — it cannot miss
a kind, because it never asks what kind anything is.

Misfiling is a SECOND AXIS, not a fourth state: every anchor can resolve and
the entry still be in the wrong file. Measured live on a real project: 3 of 3
anchors resolved and the misfiled entry was still caught. It is decided only
when something resolved — judging it on an all-not-indexable entry put the same
false staleness back through the other axis, which a table test caught and a
stubbed guard confirmed as the cause. The rule is deliberately relaxed to one
matching anchor, so a genuinely two-module fact keeps its home shard.

capture is deliberately NOT sharded. Staging is a queue, not a store, and
routing it would force the file decision before a human has looked at the entry
— the decision promotion exists to make. The queue is content-addressed, so
capturing the same fact twice is refused for free.

mxcli lint prints the unpromoted-queue count: a report only brain check prints
is a report nothing demands, which is how the analogous digest went three
months without a run. Silent with no store and no queue, on stderr so it cannot
corrupt --format json/sarif.

Sizes are computed by brain show on every call and are written into no
committed file, the store's own README included. Dropping the last entry from a
module shard removes the file, so the directory does not accumulate husks that
read as "this module has decisions".

Controls run in both directions for each guard: the cap refusal (and a write
that fits), a foreign docs/brain/ refused (and mxcli's own re-init accepted),
init not clobbering entries, --changed selecting one shard (and none on a clean
tree), and the lint notice appearing and disappearing with the queue.

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

All four slices shipped (#245, #250) while the proposal still read `draft`,
so the one artifact that says what is built was the one saying it was not.

Records what landed beyond the plan, each because running the feature
exposed the need: the enabled-language statements, the out-of-scope report
(ledger #137 — `in Ledger` never reaches the project-level NAVIGATION, so
the sidebar stayed English under a message that read as success), the
removal form, the lint rule and skill. `--untranslated` is the one planned
item still unbuilt, and it is a cost optimisation rather than a gap.

Two open questions are answered by measurement rather than deleted:

  2. An unenabled language's translations ARE stored and kept — a stock
     11.13 app enables one language while carrying nine — but the app does
     not serve them, so `create translations` warns and names
     `ALTER SETTINGS ADD LANGUAGE` instead of enabling or refusing.
  5. Studio Pro does not care about `Items` order, unlike widget
     PropertyTypes. What actually bit was the `$ID` form, which is a
     different question and now has its own bug test.

Homographs, catalog coverage, and the two default-language-less texts stay
open, with what has been learned since attached to each.

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

The brain held decisions only, so requirements, slicing and roadmap had
nowhere to live. That is a real gap: when the source of truth is a Word
document, a Figma file or a conversation, it leaves no trace in git — not an
issue, not a commit message — and hours of work end with nothing recording
what they were for.

Measured before designing, because it settles the shape in one command:
recorded as an ordinary entry, a single not-yet-built requirement takes
`brain check` to exit 1. The anchor syntax is identical but its MEANING is
inverted. A decision's anchor points backward at what exists, so one that
stops resolving means the decision is stale. A requirement's points forward
at what is intended, so one that does not resolve means not built yet — the
normal state of a requirement for most of its life.

So requirements are a second kind, in plan/<slice>.md, and that inversion is
what pays for the feature rather than merely accommodating it: a requirement
is BUILT when its anchors resolve, so `brain plan` reports progress derived
from the model. Measured end to end — a slice at 0 built / 1 planned went to
1 / 0 after creating the microflow its requirement names, with the plan file
untouched. There is no status column to maintain and none that can be wrong.

Consequences, each deliberate:

- Requirements never fail a check, and slice progress is reported instead.
  NotIndexable counts as built, not planned: the thing exists, and calling it
  outstanding would report finished work as undone.
- Misfiling is not checked for slices. A slice spans modules by design.
- A requirement with no anchor is counted apart as unanchored rather than
  silently called planned — it cannot be measured yet.
- The kind comes from the file, never from a second copy inside the entry, so
  there is nothing to drift.
- A requirement's id folds in its slice, so the same sentence can legitimately
  be a requirement of two slices.
- Slices sort by name, so a numeric prefix is how a roadmap gets its order —
  the user's choice, not a field mxcli maintains.
- A slice's cap is generous but real: a slice too long to read is a slice that
  should be split, so here the cap enforces the slicing discipline rather than
  just bounding context cost.

Also fixes a bug found by hand, not by the suite: --changed mapped a path to a
shard by basename, so docs/brain/plan/01-accounts.md became "01-accounts" and
every edited slice was invisible to it. The existing test had only ever used
module shards, which is exactly the blind spot; it now covers a plan slice, and
reverting the fix reproduces the miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…em by default

The bootstrap interview asked what the app is for and what it tracks, but never
for the requirements themselves — so a spec that lives in a Word document, a
prototype or a chat window stayed there. Nothing in the repo then said what was
being built towards, which is precisely the context an idle-reaped session has
lost.

Adds Q8 ("Do you have requirements to work from?"), defaulting to yes, and a
provisioning step that runs `brain init` and records the requirements as slices
before any building starts. Opting out is one word at the interview.

Two things the step is explicit about, because both are easy to get wrong:

- Anchor each requirement at what WILL implement it. The anchor points forward,
  so naming something that does not exist yet is correct here — and it is what
  makes `brain plan` a progress report rather than a checklist. The model
  proposal step now says to name elements the way the plan's anchors do, so the
  count starts moving the moment work lands.
- Never write a status or a tick-box beside a requirement. Whether it is built
  is computed from the model; a hand-kept one is wrong as soon as anyone builds
  anything, and nothing will say so.

README.md stays the brief — one page, for a human landing on the repo.
docs/brain/plan/ is the scope — anchored, countable, appended to as
requirements emerge mid-build, which they do.

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

SHOW LANGUAGES listed 8 of a project's 9 languages, and `search 'Opslaan'`
returned nothing for a caption `describe translations` had just listed.

CATALOG.strings was filled by per-type extractors written by hand — page
titles, enum captions, three microflow message templates — so a text
anywhere else was never indexed. Measured on testdata/expr-checker:

                       indexed   actually in the project
  translatable texts       ~69                      3265
  languages                  8                         9   <- ar_DZ missing
  en_US translations        66                      1045
  extraction sites           5                        17

A language present only on an unindexed site is INVISIBLE rather than
undercounted, so it is missing from SHOW LANGUAGES entirely — and from
lint rule QUAL005, which discovers its language set from the same table.

The fix is not more cases. A sixth site cost a sixth hand-written case,
which is how five was ever the number. Rows now come from the
type-agnostic Texts$Text walk that DESCRIBE TRANSLATIONS already uses, so
the two subsystems cannot disagree about what the project contains; the
typed path keeps only the strings that are not translatable at all (URLs,
log node names, REST paths, documentation, and the Microflows$StringTemplate
a workflow name is stored in — a plain Text, not a Texts$Text).

StringContext now names the site (Forms$ActionButton.Caption rather than
page_title) and ObjectType is derived from the unit $Type mechanically, so
a document type Mendix adds later is named correctly with no list to
maintain. Nothing queried the old vocabulary outside test fixtures.

Atlas design templates are ~70% of the corpus and never render in a running
app, but they are indexed rather than excluded: CREATE TRANSLATIONS writes
them, and a SHOW LANGUAGES that excluded them would reopen the same split
this closes. ObjectType is how a consumer filters them out.

Same project after: 1496 rows, 9 languages, en_US 1045 / nl_NL 333 /
ar_DZ 4 — identical to an independent BSON walk of the units.

Control: stubbing the walk gives `strings: 3` and a SHOW LANGUAGES that
reports nothing, so "9 languages" is not equally consistent with a build
that never had the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
The rule grouped by (QualifiedName, StringContext) while ElementId sat
unused in the table. Sibling elements of one type share both — an
enumeration's twelve values, a page's action buttons — so they became one
group, and translating a single value made the whole set look complete.
Eleven real gaps went unreported.

Grouping now includes ElementId, which the strings index already carried.

No test caught this because the test harness synthesized ElementId from
QualifiedName+StringContext, giving every sibling the same value and
reproducing the defect in the fixture. The new tests set it explicitly.

Control: with every sibling translated the run stays at 0 violations, so
the violation in the failing case is the missing translation and not an
artifact of splitting the group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
A `call external action` was written with neither the result variable's
type nor its parameters' types, so Mendix reported CE7269 ("the return
type for remote action '<x>' has changed") and CE7252 ("the parameters
... have changed"), and re-running CREATE OR MODIFY EXTERNAL ENTITIES
never cleared them.

It never could. Both codes are defined on CallExternalAction.cs
(extracted from Mendix.Modeler.Texts.dll, 11.13): they are raised by the
microflow ACTIVITY, not by the entity. That is what made the reported
remedy the wrong lever, and it cost the reporter a debugging session.

Two omissions of the same shape, each a DataTypes$ sub-document that was
never written:

  - The return-type resolver mapped only EDM primitives and returned ""
    for anything else, so an action returning an entity (or a collection
    of them) got no VariableDataType at all. It now resolves to
    DataTypes$ObjectType / DataTypes$ListType naming the external entity
    imported for that type -- the same linkage the entity import writes.

  - ExternalActionParameterMapping.ParameterType was never written,
    though generated/metamodel declares it WITHOUT omitempty. Measured: a
    call with ANY parameter, of any type, produced CE7252 plus one CE0117
    "Error(s) in expression" per argument, because an argument cannot be
    type-checked against an untyped parameter.

Measured on 11.13 against a contract with three action shapes: before, a
no-parameter entity return was CE7269, a one-string-parameter call was
CE7252 + 1x CE0117, and a two-parameter call CE7252 + 2x CE0117; after,
all three build at 0 errors. Reverting the return resolver reproduces
CE7269 verbatim.

`check --references` now resolves the call against the cached contract
too, so an unknown action, an undeclared argument, a missing parameter,
or an entity return whose entity has not been imported are reported with
the statement that fixes them.

Also: the catalog listed only entities stored as
Rest$ODataRemoteEntitySource, skipping every Rest$ODataEntityTypeSource
-- what CREATE EXTERNAL ENTITIES writes for any type the contract gives
no entity set, including an action's parameter and return types. The
consequence was worse than the under-count: contract_entities.
UsedByExternalEntity is filled by joining that table on RemoteName, so
for exactly those entities the column was structurally always empty and
read as "linked to nothing" whether or not the import had worked. That
column is the evidence the report was diagnosed from.

Fixes mendixlabs#1020

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
fix(odata): type an external action's return value and its parameters
docs(proposal): restate the brain's CLI surface and skill against sharding
fix(mappings): carry OriginalValue and the snippet formatting through a rewrite
feat(brain): project knowledge store — decisions, requirements and slices
Conflict was CHANGELOG.md only: both sides appended independent entries
under [Unreleased]. Kept all nine — main's seven and this branch's two.

Checked the findings shards for the union-merge trap this repo has hit
twice (merge=union never deletes, so a record main removed comes back):
every shard matches main exactly except mdl-other.jsonl at +2, which is
this branch's own two records. Nothing resurrected.

main's 19 commits touch mdl/catalog/ (builder_contract.go,
builder_external.go) but none of the files changed here, and the grammar
moved again so the parser was regenerated. Re-verified on the merged
tree: go test ./..., make lint, make check-findings green, and the
end-to-end measurement unchanged at 9 languages / en_US 1045 / ar_DZ 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
feat(catalog): index every translatable string, not five hand-picked kinds
@ako
ako merged commit 191a0c9 into mendixlabs:main Sep 3, 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