Skip to content

Seventeen changes, mostly from one real project: a decision outcome that made projects unopenable, a glyph that broke deploy at 0 errors, and offline sync - #1075

Merged
ako merged 48 commits into
mendixlabs:mainfrom
ako:main
Sep 8, 2026

Conversation

@ako

@ako ako commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Seventeen commits. Most came from running mxcli against one real application (ako/CapTrackV4) and one reference project (ako/TestApp), which is why so many of them share a shape: every gate was green and the thing was still broken.

Broken in ways nothing before deploy would catch

  • An unqualified decision outcome makes the project UNOPENABLE (mendixlabs/mxcli#1031, #1065). Reported success, then failed where the author could not see it. The same commit stops a crash on an unquoted mapping value.
  • A glyph code broke the deploy at 0 errors. icon glyph 57562 is a bare integer, so nothing resolved it: mxcli check and mx check both passed, and the deploy died with "An exception occurred while exporting layout". Now MDL078 validates the code against the font — and since a glyph is not a model document, SHOW GLYPHS / DESCRIBE GLYPH were added so the rule can name real alternatives instead of numeric ranges with holes in them.
  • A NULLABLE OData parameter gave CE7252 with no MDL to clear it (mendixlabs/mxcli#1073) — ExternalActionParameterMapping.CanBeEmpty was never set.

Advice that was actively wrong

  • mxcli lint QUAL004 told you to delete your after-startup microflow. A microflow wired as AfterStartupMicroflow reported no callers and no references — "not called from anywhere. Remove if unused." Taking that advice left a dangling name mx check did not catch either; it surfaced only when the runtime refused to start. A project setting now counts as a reference.
  • mxcli lint QUAL002 reported "no documentation" on documented pages. The modelsdk reader dropped a page's and snippet's documentation, so the catalog's Description was blank, describe page emitted nothing, and the javadoc comment looked dropped from every angle.
  • CONV010 flagged an ACT_ microflow for its own closing brace. ALLOWED_ACTIVITY_TYPES held ExclusiveSplit but not ExclusiveMerge, and an if produces both — so guarding anything, the most ordinary thing an action microflow does, was a violation while the branch it closes was permitted.

Checks that under-reported or resolved the wrong thing

  • check --references under-reported project conflicts. Two switches with nothing comparing them: one classified 24 document types, the other knew 20. The four that fell through returned nil, read as "no conflicts".
  • MDL-WIDGET11 resolved datagrid against the deprecated Atlas Core DataGrid while MDL's datagrid has always written Data Grid 2 from DataWidgets. Their design properties are disjoint, so the advice was for a different widget.
  • An enumeration inside a folder was reported missing by check --references, while DESCRIBE ENUMERATION returned its values and SHOW ENUMERATIONS listed it.
  • A quoted entity name failed in a snippet parameter but worked identically in a page parameter — reached by following the project's own convention of quoting every identifier, with no clue which spelling was the odd one.
  • alter page … set PageSize failed on a DataGrid 2 that create page (PageSize: 20) had just written: the property was resolved by casing rather than by name.

Idempotence

  • DROP USER ROLE / DROP DEMO USER … IF EXISTS. Neither had an idempotent form, so a one-time cleanup of what mxcli new ships either broke every later run of its slice script or had to be commented out — which is what a real project did.

Offline synchronization

Phase 1 of a proposal pinned against a real document: an offline entity config's stored properties are now carried on read. CompatibilityMode was read and discarded, so any future write path would have dropped it silently — the document stays valid and mx check reports 0 errors either way.

Measured on ako/TestApp, whose TabletOffline profile configures seven entities across all six sync modes: Studio Pro writes four properties, not the six modelsdk/gen declares. DownloadMode and ShouldDownload occur zero times, so the risk is inverted from the expected one — a property Studio Pro fills in on load is one whose emission makes a document Studio Pro cannot open.

Also fixed: describe navigation emitted a broken comment, because a real constraint is multi-line and a newline ends a -- comment.

Proposals

  • Offline synchronization configuration, pinned against ako/TestApp — all four of its open questions closed by the document, two answers not what the draft assumed.
  • First-class expressions for expression-typed MDL properties, written for mendixlabs/mxcli#750. It corrects the framing: expressions are not stored as strings — the visitor already parses them into typed AST nodes — and the reason the issue stalled is that there are two expression families, XPath and Mendix expression, which should not be unified behind one delimiter

claude and others added 30 commits September 7, 2026 07:41
…uses

`mxcli check --references` under-reported. Two switches in
validate_duplicates.go with nothing comparing them: stmtCreateInfo classified
24 document types, projectNameSets.setFor knew 20. The four that fell through
returned nil, which the caller reads as "no conflicts for that type".

The cost is not a missing warning. `exec` stops at the first conflict having
already written every statement before it, so a script that only fails at
execution time leaves the project half-modified. Measured on a three-statement
script: check said "Check passed!", exec created the first entity, died on the
rule, and never reached the third statement.

Three of the four gaps were real and are now wired up — association, rule and
javascript action, each of which exec refuses with "already exists". The
fourth, module, is a deliberate exemption: CREATE MODULE is a no-op when the
module exists (prints "already exists", exits 0), which is what lets
`create module M;` open nearly every script. It is recorded as an exemption
with its reason rather than left as an omission.

A second, pre-existing defect surfaced while sweeping for false positives:
stmtCreateInfo's idempotent flag read CreateOrModify only, missing the third
spelling, IF NOT EXISTS. Exec skips such a statement with "already exists —
skipped", but check reported it as a conflict — so a re-runnable domain script
failed its own second run at check time. Fixed for entity and association, the
two statements that carry the modifier.

Guards, which are the actual fix for the class: three tests read BOTH lists out
of the Go source with go/parser rather than restating them, since a guard that
keeps a third copy of the list joins the defect instead of ending it. Each
t.Fatals on an empty extraction so it cannot pass vacuously. Writing the mirror
guard paid immediately — it found that stmtDropInfo had no DropRuleStmt case,
so making rules project-checked would have turned `drop rule X; create rule X;`
into a false positive this change was about to introduce.

Measured, over the 88 example scripts that exercise the changed paths, each
exec'd into a fresh project and then re-checked: 621 conflicts before, 724
after. 105 added, every one verified to trace to a plain CREATE in its script;
2 removed, which were the pre-existing IF NOT EXISTS false positives. Controls:
reverting each of the four parts of the fix makes its test fail with the
reported symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
`ALLOWED_ACTIVITY_TYPES` held ExclusiveSplit and not ExclusiveMerge, and an
`if` produces both. So an ACT_ microflow that guards anything — "do not
open a page with an empty parameter", the most ordinary thing an action
microflow does — was flagged for its own closing brace while the branch it
closes was permitted.

Measured on a microflow whose ONLY violation was the merge, and 122 times
over on one real project (ako/CapTrackV4 R11).

This package had already settled the question in the other direction:
countMicroflowActivities excludes ExclusiveMerge as structural, with a
comment saying so. CONV010 was the only place treating it as business
logic.

The test asks the catalog's own labeller for the two names rather than
hardcoding them, matching the existing action test, and a control keeps
LoopedActivity and InheritanceSplit flagged — a fix that widened the list
to "anything that is not an ActionActivity" would pass the first test and
gut the rule.

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

A microflow wired as the project's AfterStartupMicroflow reported no
callers and no references, and `mxcli lint` QUAL004 said "not called from
anywhere. Remove if unused." Taking that advice left a dangling name that
`mx check` did not catch either — it surfaced only when the runtime
refused to start (ako/CapTrackV4 049, R13).

The runtime is what calls these, so nothing in the model does, and the
reference graph had no edge for it. Three settings name a microflow:
AfterStartupMicroflow, BeforeShutdownMicroflow and HealthCheckMicroflow.
Each now emits a `settings` edge, the same shape and for the same reason
as the `schedule` edge a scheduled event emits.

The source of the edge is the SETTING, not the project, so `show
references to <microflow>` names which setting depends on it — the thing
you need before dropping it, and otherwise only reachable by reading
`describe settings`.

The list of settings is a literal rather than reflection over
ModelSettings, because most of that struct is strings that are not
microflow names and a wrong entry would invent an edge rather than miss
one.

Verified on a real project: `show references to` goes from "no references
found" to naming AfterStartupMicroflow, and QUAL004 stops reporting it
while still reporting a genuinely uncalled microflow beside it.

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

MDL-WIDGET11 resolved `datagrid` against Atlas Core's `DataGrid` — the
DEPRECATED data grid — while MDL's `datagrid` has always written Data grid
2 from the DataWidgets module. Their design properties are disjoint:

  Atlas Core   DataGrid                                  Style, Hover style, Row size
  DataWidgets  com.mendix.widget.web.datagrid.Datagrid   Borders, Compact, Hover, Striped

So the tool warned that Compact / Hover / Striped were "not defined for
this widget type" — they are exactly its properties — and suggested Style
and Row size, which mxbuild refuses with CE6083 "not supported by your
theme". Taking the advice turned 16 warnings into 17 build errors
(ako/CapTrackV4 010). The warning now flips the right way round on the
same two scripts: 3 warnings to 0 on the working one, 0 to 2 on the one
that does not build.

Three more keywords were wrong in the quieter direction. `combobox`,
`gallery` and `image` named keys that no web design-properties.json
defines, so the registry lookup missed and validateWidgetDesignProps
skipped those widgets entirely — silence that reads as approval. A gallery
with a made-up design property is now reported.

The fix derives the key rather than listing it, from the two places that
already decide which widget a keyword writes: keywordDispatchTable and the
embedded widget definitions. The hand-written table keeps the native
widgets, which are the majority. A test fails if a keyword appears in both
halves, since the native entry would be dead code.

Each of the four pairings was measured by writing the widget and reading
its type back out of the catalog, not inferred from the builder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`create or modify snippet M.S (params: { $T: Mod."Thing" })` failed at
execution with `failed to resolve entity Mod."Thing": entity not found`,
while the identical quoted form in a PAGE parameter resolved fine
(ako/CapTrackV4 019). The project convention is to quote every identifier,
so this was reached by following the house style, and the asymmetry gives
no clue which of the two spellings is the odd one.

The cause was one line: buildSnippetParameterListAsPage re-split the parse
node's TEXT, and GetText() returns the source verbatim, quotes included.
The page path has always walked the parse tree, where buildQualifiedName
unquotes each part.

A correct implementation already sat beside it — buildSnippetParameters,
which nothing called. Two copies of one conversion, one of them dead, is
how they drifted; the dead one is removed rather than left to be the next
thing someone fixes by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`mxcli lint` QUAL002 reported "Page 'X' has no documentation" against a
page carrying a javadoc comment, the catalog's Description column was
blank for every page and snippet, and `describe page` emitted no
documentation — so the comment looked, from every angle, like it had been
dropped (ako/CapTrackV4 R12).

It had not. The comment reaches the AST, the executor sets it and the
writer stores it: `mxcli bson dump --type page` shows Documentation with
the right value, and the LEGACY reader parses it back correctly. Only the
default engine's readers failed to carry it, and page and snippet were the
two that did, out of five sibling readers in the same file — layout,
building block and page template all had it. Every symptom the report
lists is downstream of that one omission, DESCRIBE included, which now
round-trips.

The report's third row is a correction rather than a fix. A Mendix module
HAS no documentation property, measured three ways: the metamodel's
ProjectsModule declares none, modelsdk/gen's Module offers no accessor,
and none of a real project's stored Projects$ModuleImpl units contains the
key. QUAL002 was asking for something no editor can supply and reporting
every module of every project forever, so modules are dropped from the
sweep.

That change cost the System-module test its positive control — it proved
the exclusion had not over-reached by checking the user's own module was
still reported. It now checks the user's own entity instead, since a module
being absent no longer proves anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Neither statement had an idempotent form, so a one-time cleanup of what
`mxcli new` ships either broke every later run of its slice script or had
to be commented out — which is what a real project did (ako/CapTrackV4
R5).

Re-running every mdlsource/*.mdl in order is what a fresh clone does, and
that loop is how the class was found. Five statement forms there succeeded
once and failed after; four already had an idempotent spelling
(`create or modify`), and these two had none at all, which is exactly why
they are the ones that ended up commented out.

The spelling reuses the grammar's own ifExists rule, so it matches
ALTER ENTITY's `drop attribute if exists` rather than inventing a second
way to say it. A missing target prints "does not exist, skipping" and
returns cleanly; without the clause it is still an error, which the example
exercises as a control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
A glyph code is a bare integer, so nothing resolved it: `icon glyph 57562`
passed `mxcli check` AND `mx check` at 0 errors and broke the deploy with

  ERROR: One or more errors occurred.
  (An exception occurred while exporting layout 'CapTrack.App_Default'.)

which names a document that is not the cause and never mentions
navigation. Bisecting it cost three build cycles (ako/CapTrackV4 007, R2).

Measured on 11.14.0 with two full builds of one project differing only in
the code. 57562 (0xEBDA) fails at the layout export with
InvalidOperationException "Sequence contains no matching element" at
Forms.Icons.GlyphFont.GetClass(Int32) — a LINQ .First(...) over mxbuild's
glyph table, which throws rather than reporting. 57377 (0xE021) exports
pages and layouts cleanly.

The rule checks the cmap of the font Atlas_Core ships
(glyphicons-halflings-regular.woff): 247 codes in 35 runs, which agrees
with mxbuild on both measured points. It is a literal table because the
check has to run in the project-free pass, which is how CI runs
`mxcli check`.

A warning, not an error: the table is a snapshot of a Mendix asset, and if
Mendix extends the font then refusing a newly-valid code would be worse
than the gap this closes. The message carries the way out, since mxbuild's
stack trace does not lead back to the cause — an icon collection reference
is a model reference and `check --references` resolves it before anything
is written.

Tests include the gaps (the font is 35 runs, not one range, so a
first-to-last check would accept every bad code in a hole) and the
single-code runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
One record per fix, each carrying the measurement that settled it and the
cheap discriminator that would have found it faster — `bson dump` to
separate a write bug from a read bug, an engine split to localise a
defect to one backend, and reading a widget's type back out of the
catalog rather than trusting the builder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`show icon collections` / `describe icon collection` cover icons that are
model documents. A glyph is not one — `icon glyph <n>` stores a bare
character code into a FONT — so there was nothing to list, and MDL078 had
to spell its advice as numeric ranges with holes in them:

  "use a code the font defines — 57345-57495, 57601-57753,
   57856-57952 (with gaps), or 63743"

which is close to unusable. It now points at `show glyphs` instead.

  show glyphs;                  -- all 247, with names
  show glyphs like 'star';      -- 57350 star, 57351 star-empty
  describe glyph 57350;         -- by code
  describe glyph 'star';        -- or by name

Both halves of the data come from assets Mendix ships. The CODES are the
private-use range of the cmap in Atlas_Core's
glyphicons-halflings-regular.woff — already verified against mxbuild by
two deploy builds when MDL078 landed. The NAMES are the
`.glyphicon-<name>:before { content: "\eXXX" }` rules in Atlas_Core's
bundled Bootstrap stylesheet, which covers all 247 with none left over.

MDL078's range table is gone: glyphCodeDefined now searches the same
table, so the set the rule accepts and the set the command lists cannot
drift apart.

Neither statement needs a project, for the same reason MDL078 runs in the
project-free pass: the answer is in a font, not in the model. LIKE matches
the NAME, including aliases, because that is the direction an author needs
— they know they want a star, not that a star is 57350. An ambiguous name
lists the candidates rather than picking one, since their codes differ.

The keyword-coverage guard caught the token being added without a row in
the keyword rule, which would have made `glyphs` unusable as an ordinary
identifier; a test now pins the icon clause still parsing, since GLYPH and
GLYPHS differ by one character.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`mxcli check --references` reported an enumeration as missing —

  attribute 'CriticalPathStation': enumeration not found: Approval.StationKey

while DESCRIBE ENUMERATION returned its values, SHOW ENUMERATIONS listed it,
`exec` wrote the attribute and mxbuild built the project at 0 errors
(mendixlabs#1071). A pure false negative: the only broken thing was the
checker, and it blocked scripts that are entirely valid.

It read as "enumerations are never resolved" because the reporting project
keeps its enumerations in folders, and that is the whole discriminator.
enumerationExists matched containers directly,

  if enum.ContainerID == module.ID && enum.Name == enumName

which only ever holds for an enumeration sitting in the module ROOT: one
inside a folder has the FOLDER as its container. Every other command resolves
through the container hierarchy, which walks folders up to the module, so the
reference checker was the only one that could not see inside one.

This is upstream mendixlabs#976 a second time. That fix corrected DROP's container
matching and did not sweep for the other caller of the same question — its
own test file even spells out the class ("SHOW, DESCRIBE and ALTER all use
the container hierarchy... DROP was the one command of the four"). The
reference checker was the one left.

enumerationExists now defers to findEnumeration, which deletes the duplicate
rather than patching the copy. Two implementations of "does this enumeration
exist" are what let them drift, and only the interactive one was ever
exercised; the delegation also picks up the live-over-excluded handling
(mendixlabs#914) that the copy never had.

Both call sites are covered, not just the reported one: ALTER ENTITY ADD
ATTRIBUTE (validate.go:660) and CREATE ENTITY with an enumerated attribute
(:447), which fails identically and was not in the report — measured before
fixing. The controls hold: an enumeration at the module root still resolves,
a genuinely missing one is still reported, and a foldered one still does not
answer for another module's name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
fix(check): resolve an enumeration inside a folder (mendixlabs#1071)
…r questions

The proposal listed four things a real document had to settle and said no
project on this machine had one. ako/TestApp does: its TabletOffline profile
configures seven entities across all six sync modes, including the constrained
one. Every question is now measured rather than inferred, and two answers were
not what the draft assumed.

Studio Pro writes FOUR properties, not gen's six. DownloadMode and
ShouldDownload occur zero times in the document — gen declares them and nothing
on a web profile writes them. That shrinks the carry problem from three-of-six
to one-of-four: CompatibilityMode is the only written property MDL will not
spell. It also adds a rule to the writing section, because the inverse mistake
is the expensive one: a property absent from every real document is one Studio
Pro fills in on load, and emitting it is how a document mxbuild accepts becomes
one Studio Pro cannot open.

ThrowPartialSyncError is the throw-on-reject checkbox, and it is in NEITHER
generated source — zero occurrences in modelsdk/gen and zero in
generated/metamodel. It is also on the online Responsive profile, so it belongs
to every web profile. A property neither source knows cannot go through the
codec's typed accessors at all, so it becomes a raw-BSON overlay under the
settingsoverlay rules. That is a constraint on the design, not a detail, and it
splits cleanly out of the SYNC block in the phasing.

CompatibilityMode is real and present on all seven configs, so the arbiter
question resolves in gen's favour — generated/metamodel being a 11.6.0 snapshot
that predates it, exactly the caveat CLAUDE.md documents.

The caption-to-key mapping is confirmed against stored values rather than read
off the dialog, and since all six members occur in one profile all six get MDL
words; None vs NoneAndPreserveData is a modifier rather than an unrelated word
because the difference is whether data already on the device survives.

Three smaller things the document changed. System.Language is configured, so a
rule refusing System or Marketplace entities would reject a real project. The
constraint is stored multi-line with Mendix's doubled-quote escaping, which is
the one part of the syntax with a non-obvious round-trip test. And the earlier
claim that five local projects had offline configs is corrected in place: that
scan matched the OfflineEntityConfigs key every profile carries, not the
element.

What remains unmeasured is named rather than assumed away: native profiles,
where DownloadMode and ShouldDownload are the obvious candidates for being
written, and CompatibilityMode: true, which no reference config carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven fixes from the CapTrackV4 report, plus glyph browsing
…#1073)

A `call external action` on an OData action with a NULLABLE parameter was
CE7252 "The parameters for remote action '<x>' have changed", and no MDL
cleared it.

Microflows$ExternalActionParameterMapping.CanBeEmpty was never set, so it was
Go's false on every mapping mxcli wrote. Mendix compares it against the
contract's Nullable on every build and reports the disagreement as CE7252.
mdl/types.EdmActionParameter already parsed Nullable as a three-state *bool;
the value was dropped between the parser and the mapping builder, so this
carries it rather than adding any parsing. The fix is in the shared semantic
builder, so both engines are covered by one change.

CSDL makes Nullable optional on <Parameter> and defaults it to TRUE — the
opposite of Go's zero value — so an absent attribute means nullable.

The reported premise was wrong and the bug was real; they were not the same
thing. A Studio Pro reference document (ako/TestApp, 11.14.0) settled both:
the stored mappings are {command, Argument "empty", CanBeEmpty false} and
{additional, Argument "empty", CanBeEmpty true}. So Argument is the EXPRESSION
`empty`, never an empty string — `additional = empty` was always correct MDL
and already wrote a byte-identical Argument, and `= empty`/`= null` parse at
v0.20.0 too (the three grammar rules involved are byte-identical there). What
actually differed was CanBeEmpty, which no syntax reaches because it is derived
from the contract, not typed by the developer.

Controls: reverting only the assignment takes the four-statement repro from 0
to 4 errors, one CE7252 per call; defaulting an absent Nullable to false
reproduces CE7252 on Annotate alone, which pins the CSDL default. Studio Pro's
own microflow in the same project is the 0-error control.

Not fixed, deliberately: Studio Pro also writes empty AdditionalAttributes and
IncludedAssociations markers on the call and each mapping. mxbuild 11.14 builds
at 0 errors without them and they are unverified against Studio Pro, so they
are recorded in the finding rather than guessed at.

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

Phase 1 of PROPOSAL_offline_sync_configuration.md: read-only, no syntax, no
writing. It is the precondition slice — nothing can be written safely until
everything stored is read.

CompatibilityMode was read from the model and discarded. types.NavOfflineEntity
carried three of the four properties Studio Pro actually writes, so a write
path built on that model would have dropped it with no error and no mx check
failure, exactly the way `create or modify entity` dropped access rules. It is
now carried through both engines and the convert layer.

DownloadMode and ShouldDownload are deliberately NOT added, though gen declares
them. They occur zero times in the reference document, so the risk here is
inverted from the expected one: a property absent from every real document is
one Studio Pro fills in on load, and emitting it is how a document mxbuild
accepts becomes one Studio Pro cannot open.

Two things found while doing it, neither in the plan.

TestFieldCountDrift passed VACUOUSLY. It exists to catch drift on the structs
convert.go copies by hand, and neither NavOfflineEntity nor NavigationProfile
was in its list — so adding a field left it green. Both are guarded now. A
drift guard that passes on a struct it does not know about is worse than none,
because it reads as coverage.

`describe navigation` was already broken on real data. Studio Pro writes an
offline sync constraint multi-line, and the comment interpolated it verbatim,
so the `--` ended after `where '[` and four lines of raw XPath followed as if
they were MDL. Pre-existing, and only a document carrying a real constraint
could reveal it — no local project had a populated offline config at all.

Verified end to end against ako/TestApp: all seven configs read back, spanning
all six sync modes, with the constraint folded onto one line. The table test
covers those seven plus a CompatibilityMode: true case, which earned its place
immediately — reverting the carry made it the ONLY failing subtest, so the
seven real configs alone would have passed against broken code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`alter page P { set PageSize = 10 on dgProducts }` failed with

    pluggable property "PageSize" not found

on a DataGrid 2 that `create page … (PageSize: 20)` had just written and the
app really paged at. `mxcli check --references` passed the script, so the
failure only appeared at exec, after earlier statements had already been
written; the documented workaround was to re-emit the whole page with
`CREATE OR MODIFY`, turning a two-value edit into a 190-line script.

A pluggable property is keyed in the widget template in lowerCamel
(`pageSize`). CREATE resolves the author's spelling case-insensitively (the
widget engine's lookupProperty, and WidgetV3.GetStringProp before it);
setPluggableWidgetPropertyMut compared the template key byte-for-byte, so
only the exact `pageSize` worked. DESCRIBE PAGE prints the capitalised
`PageSize:`, so describe -> edit -> exec produced a script mxcli then
refused to run.

This is the sequel to alter-page-lowercase-set-on-builtin.mdl, which fixed
the same class for first-class properties and left the pluggable fallback
case-sensitive on the belief that template keys must be matched exactly.
They are stored case-sensitively; that is not a reason to match them that
way. Measured across every shipped widget template and definition: 96
property scopes, 1208 keys, 0 pairs differing only in case — so the relaxed
match is unambiguous, and TestPluggablePropertyKeysAreUniqueIgnoringCase
keeps that true as templates are added.

Both engines share pagemutator, so the one change fixes modelsdk and legacy
(verified separately). A genuine typo still errors, which is the author's
only signal: check --references does not resolve pluggable property names.

Verified end-to-end on a real 11.13.0 project — all four spellings alter the
grid, `mx check` reports 0 errors, and the control (`set PagSize`) still
fails. The regression test fails with the reported message when the fix is
reverted.

Refs mendixlabs#1069

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
…ing on an unquoted mapping value

Two workflow defects reported upstream, both of which reported success and
then failed somewhere the author could not see.

mendixlabs#1031, mendixlabs#1065 — a decision outcome that is
not fully qualified makes the project UNOPENABLE. Mendix stores the value in
EnumerationValueConditionOutcome.Value and parses it through
EnumerationValueIdentifier.FromString in the UnitLoader, before any
consistency check runs, so the failure is a StorageLoadException with no CE
number and no "The app contains: N errors." line — while mxcli check, exec
and describe workflow all reported success.

What settled the threshold was three runs of the same workflow, one per copy
of the same app, verdict read off the literal mx check line:

  'OutcomeA'             (1 segment)  -> StorageLoadException, unloadable
  'Status.OutcomeA'      (2 segments) -> StorageLoadException, unloadable
  'WFP.Status.OutcomeA'  (3 segments) -> The app contains: 0 errors.

The two-segment row is the one worth keeping: "qualify it" is ambiguous
without it, and shortening an enumeration that lives in the same module is
exactly what an author would try. It agrees with the stored corpus, where
every EnumerationValueConditionOutcome holds Module.Enum.Value.

MDL-WF03 now requires that form, on CREATE WORKFLOW and on ALTER WORKFLOW …
INSERT CONDITION, which writes the same field through wfmutator. Because the
rule is an error and exec refuses a script with errors, the corrupting write
can no longer happen. This TIGHTENS the rule #408 had widened: that
change kept bare identifiers accepted on the reasoning that the rule was only
there to catch free text, and the loader measurement shows a bare identifier
is the corruption.

mendixlabs#1023 — an unquoted `with (Ctx = $WorkflowContext)` crashed
the binary with a SIGSEGV on check, check --references and exec alike. The
grammar requires a string literal there, but visitor.Build() walks the parse
tree even when the parse failed (deliberately — that is what lets check
report more than the first error), so under ANTLR error recovery the rule was
visited with a nil STRING_LITERAL child and read unguarded. Both mapping
sites now nil-check and skip, leaving the syntax error the listener already
recorded as what the author sees: "mismatched input '$WorkflowContext'
expecting STRING_LITERAL".

mxcli syntax taught both broken forms — the decision entry's example used
'Under 1000' / 'Over 1000', and the WITH clause was absent from both call
entries — so the docs are corrected alongside.

Verified with controls. Reverting the regex leaves the bare and two-segment
rows undetected; removing the nil guard reproduces the SIGSEGV in the test.
End to end: check refuses the bare outcome with both outcomes named, exec
refuses and writes nothing (no module created), the unquoted mapping reports
a syntax error on check and exec, and the new bug-test fixture execs into a
real 11.10.0 app at 0 errors.

Refs: mendixlabs#1031, mendixlabs#1065, mendixlabs#1023

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 of PROPOSAL_offline_sync_configuration.md. An offline profile
downloads NOTHING until each entity is given a sync mode, so before this a
profile mxcli created built, routed and installed as a PWA and showed an empty
app — with mxcli check, exec and mx check all clean.

Full pipeline: lexer -> grammar -> AST -> visitor -> executor -> both backends
-> describe, plus the syntax topic, quick reference, skill and a doctype
example exercising all six modes.

  sync (
    sync Mod.Setting online;
    sync Mod.Vehicle all;
    sync Mod.Trip where '[Distance > 0]';
    sync Mod.Audit never;
    sync Mod.Lookup none;
    sync Mod.Draft none preserve data;
  )

WHERE implies Constrained rather than naming it, so a constraint without a mode
and a mode without a constraint are both unspellable rather than merely
diagnosable.

The write is an overlay keyed by entity, not a rebuild: CompatibilityMode is
stored, has no syntax, and is put back untouched. Every reference config
carries false, so a writer that always emitted false would look correct against
all seven — the true case is the only thing that distinguishes them, and it is
the test that fails when the carry is broken. DownloadMode and ShouldDownload
are deliberately not written though gen declares them; they occur zero times in
the reference document, and a property Studio Pro fills in on load is one whose
emission makes a document Studio Pro cannot open.

Both engines got the same overlay, with a parity test. Two writers drifting
apart is how an engine-specific defect hides.

The enum guard mendixlabs#1035 earned: every MDL word maps to a declared
Navigation$SyncMode member, every member has a spelling, and Studio Pro's
captions ("All Objects", "By XPath") are refused rather than written — they are
not members of the enumeration at all.

The gates caught a collision this feature's own tests structurally could not.
NEVER is already a page property value (`editable: never`), so the new token
broke a previously-passing unrelated example until all four words were added to
the keyword rule. Recorded as a finding: grep the examples for a word before
making it a token, and test that it still parses as an identifier — the
coverage test only asserts the rule lists it.

Verified against ako/TestApp: describe -> exec -> describe is byte-identical,
including the six-quote constraint (the stored XPath carries Mendix's escaping
and MDL's doubling composes with it); the written BSON is marker 3 plus exactly
the four properties, with ThrowPartialSyncError and the PWA settings untouched;
mx check reports 0 errors both there and on a project the new example builds
from scratch.

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

The SYNC block took `where '<xpath>'`, a quoted string, so every quote inside
doubled. A stored constraint already carries Mendix's own escaping, so the two
compose: ako/TestApp's constraint came back as SIX consecutive quotes. Correct,
verified by round trip, unreadable.

It was also inconsistent. The bracket form is the first-class one and predates
this work: `retrieve … where [...]`, `visible: [...]`, `editable: [...]` all
take it, and `describe microflow` already emits a retrieve's constraint
unquoted. Only the new block was the outlier.

WHERE now takes `xpathConstraint` and DESCRIBE emits it, so nothing is escaped:

    where '[ ( contains(ActionValue, ''''''abc'''''') ) ]'   before
    where [ ( contains(ActionValue, '''abc''') ) ]           after

The remaining triples are Mendix's own escaping inside the stored XPath — they
encode the literal 'abc' — so reproducing them verbatim is right. MDL adds
none. The quoted form still parses, because scripts contain it, and a test
asserts the two forms produce an identical stored constraint.

Also fixes a defect the change exposed. singleLine folded whitespace with
strings.Fields, which collapses it INSIDE string literals too, so a constraint
containing 'two  spaces' would silently match on 'two spaces' — a change to the
value being selected, in a place nobody would look. The fold now tracks quote
state and treats a doubled quote as an escape rather than a close. The first
attempt at that was itself wrong, emitting the deferred space inside the
literal it preceded; the table test caught it.

Round trip converges after one write and is stable thereafter — the one-time
change is normalizeXPathTokens, which every retrieve constraint already goes
through, so it is existing behaviour rather than something introduced here.

Reported upstream as mendixlabs#750, whose general case is now
PROPOSAL_first_class_expressions.md.

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

Written for mendixlabs#750, which lists four design questions and defers
them to a proposal. It has sat two months, and the reason is in question one.

The framing needs correcting first. "Expressions are stored as strings" is the
natural reading and is wrong — PROPOSAL_expression_type_checking.md already
corrected it once, in its 2026-06-19 revision: the visitor parses expressions
into typed mdl/ast nodes, XPath included, and a retrieve's constraint already
round-trips first-class. The defect is narrower: specific properties are
declared as generic quoted-string slots while the expression grammar sits
unused beside them. dynamicclasses is handled by name as an ordinary string
property and never meets an expression rule at all.

The blocking question is that there are TWO expression families, not one. The
bracket form parses xpathExpr and serves visible/editable/retrieve-where, while
dynamicclasses is a full Mendix expression with if/then/else. The
recommendation is not to unify them: one delimiter over two grammars means
either a parser that guesses the language or an XPath rule quietly extended
until it accepts if/then/else, and a value parsed under the wrong grammar
stores cleanly and fails at build. dynamicclasses should take the UNDELIMITED
microflow expression form, because brackets read as XPath everywhere else.

Sized rather than asserted: 23 runs of four-or-more consecutive quotes across
the shipped skills and examples, twelve of them five long. The worst case is
multiplicative rather than additive, and turns up wherever a stored value
already carries Mendix's escaping.

DESCRIBE is the point rather than a nicety. Three shipped bugs share one root —
a describer that has to escape eventually will not: mendixlabs#1006 emitted TARGETING
USERS XPATH without doubling and failed its own check, #394 emits unescaped
quotes in enumeration captions, mendixlabs#642 mis-stored every quoted where-constraint
as CE0161. Emitting a form that needs no escaping removes the class.

Ships in family order, XPath first because it is additive — a worked precedent
exists in the offline-sync SYNC block, one grammar alternative and eight lines
of visitor.

Leaves one open question rather than closing it silently: whether any other
path folds or normalises whitespace without tracking quote state. The
offline-sync describer did, collapsing runs inside string literals, and that
defect leaves no trace — the document stays valid and the build stays green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the conflict GitHub reports on the findings shards. They are
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. Verified both sides survived: 84 -> mine 85,
main 85, merged 86 on mdl-backend, and the same shape on mdl-executor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#418 took a merge of origin/main to resolve the findings-shard conflict
GitHub reports but the union driver resolves locally. Phase 2 is stacked on
it, so it needs the same base or its diff shows phase 1's changes twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(navigation): carry an offline entity config's stored properties on read
fix(workflow): refuse an unqualified decision outcome, and stop crashing on an unquoted mapping value
ako and others added 18 commits September 8, 2026 17:20
…create a profile

CI failed on the new doctype example: TestMxCheck_DoctypeScripts runs every
script through exec + mx check on BOTH engines, and the script creates a
PhoneOffline profile.

    Execution error: failed to create navigation profile: creating navigation
    profile "PhoneOffline" needs the modelsdk engine

That refusal is deliberate, not a gap. Creating a profile means writing a
fourteen-key Navigation$NavigationProfile pinned against a Studio Pro
reference, and the legacy writer has no such path — "a profile assembled from a
guess builds clean and will not open in Studio Pro" (mdl/backend/mpr/backend.go).
It is the same shape as the existing menu, rule and layout skips.

What is modelsdk-only here is the PROFILE CREATION the script needs, not the
feature under test: the SYNC block is implemented on both engines and unit
tested on each, including the CompatibilityMode carry and the property set.

Nothing local catches this — `mxcli check` needs no engine and a local exec
uses the default. Recorded as a finding so the next doctype example does not
learn it from CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SYNC work landed with the syntax topic, the quick reference and the
manage-navigation skill updated, and three user-facing places missed.

docs-site/src/reference/navigation/alter-navigation.md had the SYNC clause
absent from its synopsis and, worse, a stale profile list: `Responsive`,
`Tablet`, `Phone`, `NativePhone` — no offline kinds at all, which predates this
work. A reader of the reference page could not learn that offline profiles
exist, let alone that they need a SYNC block. Adds the three offline kinds, the
clause, the six modes and the caption-vs-key warning.

docs-site/src/language/navigation-profiles.md had the same stale table and no
offline section; it now carries both and links to the reference.

CLAUDE.md gains the feature entry, including the two things a future change
would otherwise have to rediscover: DownloadMode/ShouldDownload must NOT be
written though gen declares them, and CompatibilityMode's carry is only
distinguishable by a synthetic true case because every reference config is
false.

`make check-skill-mdl` passes — it runs `mxcli check` over the MDL blocks in
the site docs and skills, so the new examples are verified rather than
plausible: 205 blocks checked, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #420 was stacked on #418 and merged into #418's branch rather than into
main. GitHub retargets a stacked base only when the base branch is deleted on
merge, and it was not — so phase 1 landed and all of phase 2 was stranded on a
dead-end branch. This carries it forward unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`mxcli new`/`init` writes a CLAUDE.md that every context started in the
project re-reads, so its size is a per-session tax rather than a one-off.
Measured on a real 15-slice build (ako/CapTrackV4): 6,918 tokens re-read
per context, ~104k tokens across the run, none of it ever wrong and none
of it ever needed in full.

About 4,800 of those tokens were tables transcribing what mxcli answers
itself — the MDL command reference by domain, the MDL syntax summary, the
lint rule table and the skill table. That is the exact thing the project
brain's own rule forbids writing down, and for the same reason: a copy
disagrees with the tool on the next release. It already had:

  - 10 of the 14 registered built-in lint rules, missing MPR008-011 —
    MPR008 being precisely the rule whose advice projects argue with;
  - "27 additional rules" against 31 shipped .star files;
  - no layouts, rules, scheduled events, queues, menus or regular
    expressions in the command tables, all shipped since it was written.

This is mendixlabs#906 repeating: the skill table drifted to 12 of 68 before anyone
noticed, and nothing failed when it did.

So the tables are replaced by the commands that own them — `mxcli syntax
<topic>` (which has a --json mode built for this consumer), `lint
--list-rules`, `help <command>`, and the skills' own frontmatter, which is
the index now. What stays is what no command can say: where the binary is,
the gates and their order, the quoting rule, the communication rules, and
read the brain first.

Three tests pin it rather than leaving it to review: a byte budget, a check
that no registered rule ID or MDL command row appears in the file, and a
check that every command it now points at is actually registered. The rule
list is extracted to builtinLintRules() so the test reads the real registry
instead of a second copy of it — a second copy being the drift it exists to
prevent.

Measured: 27,591 -> 4,175 bytes (~6,893 -> ~1,043 tokens), 85% smaller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qdci88mRumJGXU5ifseAeq
`brain capture` is an O_APPEND write and was measured concurrency-safe:
three simultaneous captures land as three well-formed lines. That
measurement is right, and it is easy to read as covering more than it does.

`promote` and `drop` are not appends. Both do Load -> rebuild the file in
memory -> os.WriteFile it back whole, with no lock, so a capture landing
between the load and the write is overwritten by a snapshot taken before it
existed. Nothing reports it: the queue stays well-formed and one line is
simply gone.

The cost of that depends on what the queue is for. In one long session a
lost capture is a note whose author can still recall it. Under one
sub-agent per slice — the shape the brain's own sharding is designed for —
the queue is the only channel between slices, so a capture is the agent's
return value and losing one loses the decision. An orchestrator promoting
slice 7's entries while slice 8's agent captures is exactly this
interleaving.

So the read-modify-write is serialised with an O_EXCL lock file (rather
than flock, which does not mean the same thing on the Windows builds), and
the rewrite goes through a temp file and a rename so a reader never sees a
torn queue — Load parses a JSON object per line, so a truncated file reads
as corrupt and blames capture for what an interrupted promote did. A lock
older than 30s is taken over, because a crashed process must not leave a
project whose agents can no longer record anything.

Control, as the checklist requires: with the lock stubbed to a no-op and
the atomic write left in place, TestQueueDoesNotLoseACaptureRacingAPromote
fails at round 1 with "2 of 6 captures were silently lost". With the lock
it passes under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qdci88mRumJGXU5ifseAeq
An orchestrator dispatching one sub-agent per slice has to DECIDE on what
the brain reports — whether a slice staged anything, whether the store
still checks out — not read it. Today it cannot: every brain subcommand
prints for a human only.

The improvement note this came from assumed otherwise ("--json is already
global, so the output side is done"). It is not: globalJSONFlag is parsed
in rootCmd's PersistentPreRun but only consulted by commands that carry a
--format flag, and no brain subcommand does. `staged`, `plan` and `show`
Printf unconditionally, and `check` has --ci, which prints only the
problems and none of the states or counts.

So `staged`, `plan`, `show` and `check` now honour --json, and --json wins
over --ci where both are given: both exist for a machine, and only one of
them carries the whole report.

Two details are load-bearing rather than cosmetic:

  - AnchorState is an int, so a plain marshal would emit 0/1/2 — a contract
    whose meaning lives in the order of a const block, and where inserting
    a state silently reassigns every existing value. The three states are
    the substance of the check (only the middle one is a failure), so they
    marshal as their names.
  - The report carries a derived "failed", computed at marshal time rather
    than stored so it cannot disagree with Failed(). Otherwise every
    consumer re-implements which states are defects, and a not-indexable
    anchor or an open question — neither of which is one — is exactly the
    thing they would get wrong.

`staged` also reports each entry's derived shard, so a caller knows where a
promote would put it without reimplementing the routing rule, and an empty
queue is still an object with a count rather than the words "Nothing
staged" — the case a dispatcher most needs to parse.

Tested through the real root command, so the persistent flag and
PersistentPreRun are exercised as a caller gets them, with two controls:
without --json the output must NOT parse as JSON (otherwise a change
emitting JSON unconditionally would pass), and with the staged wiring
reverted the test fails on the human table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qdci88mRumJGXU5ifseAeq
`brain staged` lists everything ever captured and offers no way to narrow
it, so an orchestrator running one sub-agent per slice cannot ask the one
question it needs — did this slice record anything — and so cannot refuse
to advance on a slice that recorded nothing.

Which filter answers that is decided by what an entry carries, and the two
obvious candidates do not:

  - A slice filter matches Entry.Slice, and `capture --slice` is what MAKES
    an entry a requirement. A decision found while building slice 07 carries
    no slice at all, and a slice's findings are mostly decisions — so a
    slice filter silently answers a narrower question than the one asked.
  - Entry.Date is a day, so every capture in a session shares one value and
    a boundary inside a day does not exist.

The queue is append-only, so its own order is the honest timeline: --since
<id> returns everything staged after the entry that was last there. A
dispatcher notes last_id before handing the slice off and passes it back
afterwards. --slice is added too, for the different, plan-shaped question
of what scope is queued for a slice; the help says which is which.

Three details are what make it usable rather than merely present:

  - An unknown --since id is an ERROR, not an empty result. Empty is the
    signal the dispatcher acts on, so a typo or a since-promoted id
    producing it would abort a slice that had in fact done its job.
  - last_id is reported from the unfiltered queue and even when nothing
    matched, because a slice that staged nothing still has to hand the next
    slice a boundary — otherwise the next one re-reports this one's captures.
  - A zero count is marked "filtered", since the number alone cannot say
    whether the queue is empty or the filter matched nothing, and only one
    of those means the slice fell short.

--fail-if-empty exits 1 so a shell dispatcher does not have to parse for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qdci88mRumJGXU5ifseAeq
The store is sharded so a session can load project.md plus the modules it
is touching instead of the whole thing, and the README says exactly that —
"what lets a session load the shards for the modules it is touching". But
nothing produced that pack. docs/brain/ is a directory, so a session either
read all of it or guessed, and both are wrong in the same direction.
Measured on a real 15-slice project: the whole store is 7,531 tokens and
the correct pack for one slice is ~2,530.

That is a rounding error in one long session, where the store is read once
and then cached. It is a third of the context under one sub-agent per
slice, where the pack is re-read from a cold start every slice — which is
the shape the sharding was designed for and the only one in which it was
not actually usable.

  mxcli brain brief --slice 07-planning
  mxcli brain brief --module Sales --module Finance

Which module shards a slice needs is DERIVED from that slice's
requirements' anchors, not configured. Asking the caller which modules its
slice touches would be asking it the thing it opened the brief to find out.
Every anchor counts, not just the first: the first anchor decides where an
entry is filed, but a requirement spanning two modules is worked in both,
and a session that read only one of them is the case this prevents.

Ordering is project, then modules, then plan — a session reads the
decisions it must not contradict before the scope it is about to build.

The pack goes to stdout and the size line to stderr, so `brain brief | ...`
pipes the content and not the commentary; --json returns the shards
separately with their paths. --slice and --module together are refused
rather than merged: a brief's value is what it leaves out, and silently
widening it is the whole-store read it exists to replace.

`brain plan --slice <name>` is added alongside, for the same reason — it
printed all 15 slices whether or not the caller cared about 14 of them.

The test that matters is the negative one: a brief must NOT contain the
module a slice does not touch, or another slice's plan. Without it the
suite would pass against a brief that concatenated the whole store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qdci88mRumJGXU5ifseAeq
`mxcli rename` "renames an element and automatically updates all
cross-references" — within the model. The brain's anchors are references to
the same elements and were not among them, so a refactor invalidated them
silently.

`brain check` can only report half of that, and the missing half is
inherent to the design rather than a gap in the check. A decision's anchor
points backward at something that exists, so one that stops resolving is
reported NOT FOUND. A requirement's points forward at something intended,
so one that stops resolving counts as PLANNED — which is exactly what a
forward anchor failing is supposed to mean. Once the old name is gone there
is no way to tell "never built" from "built, then renamed". The observed
symptom was a plan's progress moving from 65/65 to 63/65 with nothing else
to see.

That ambiguity is the argument for fixing it at the rename, where both
names are still known and nowhere later.

So rename now rewrites matching anchors in docs/brain/ and in the staged
queue, reports the count, and previews it under --dry-run. Four decisions
in it are load-bearing:

  - The match ends at a name boundary. Anchors are dotted names, so a plain
    prefix replace turns @Sales.OrderLine into @Sales.PurchaseOrderLine on
    a rename of Sales.Order — a name that resolves to nothing, which
    `brain check` then reports as a stale decision. The repair would invent
    the exact problem it exists to prevent. (Control: with the naive
    replace, the boundary test fails with both anchors rewritten.)
  - A module rename moves modules/<Old>.md to modules/<New>.md. Without it
    every entry in that shard anchors into New the moment the rewrite lands
    and reads as misfiled — one false signal traded for another.
  - Entry ids are NOT re-derived, though they are content-derived and their
    content includes anchors. An id is a handle (`brain promote <id>`, prose
    that cites one); invalidating every reference TO an entry in order to
    fix that entry's references to the model trades one dangling pointer
    for several.
  - A shard with an unparseable entry block is refused rather than
    rewritten, because SaveShard re-renders the file from what it parsed and
    would drop the block.

A type an anchor cannot name is skipped rather than guessed at, and a
failure to update the store is a warning rather than a failed command: the
rename itself has already been applied and is what the user asked for.

Verified end to end on a real project: `@Sales.Order` and a requirement
anchoring it were rewritten, `@Sales.OrderLine` was not, ids were unchanged
and check stayed OK. The control — renaming the entity and putting the
anchors back by hand, which is the old behaviour — reproduces the reported
symptom exactly: 1 of 1 requirements built becomes 0 of 1, and the decision
half exits 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qdci88mRumJGXU5ifseAeq
A `call microflow` in a workflow's MAIN flow was auto-wired to
`with (Ctx = '$WorkflowContext') outcomes DEFAULT -> { }`; the identical
statement inside a decision's enum branch was written with neither, and
mxbuild rejected it with CE6685 + CE6686 once each. `mxcli check` passed
and `exec` reported success.

The bug is per OUTCOME KIND, not per nesting depth — the discriminator was
in the reported script all along, since the decision's `''` (void) branch
was fine and its two `'Module.Enum.Value'` branches were not.
autoBindActivitiesInFlow enumerated the flows to recurse into with a type
switch handling BooleanConditionOutcome and VoidConditionOutcome only:
EnumerationValueConditionOutcome was absent from both the decision and the
call-microflow arm, and no boundary-event body was entered at all.
deduplicateActivityNamesInFlow walks the same tree from a second,
independently maintained switch and had the boundary-event half of the
same gap.

Both walks now enumerate nested flows through one nestedFlows helper.
Condition outcomes go through the ConditionOutcome.GetFlow interface,
which already exposed the accessor the switches were standing in for, so
no outcome kind can be skipped by omission — patching the two switches
would have left the third walk to be written wrong.

Scope was 3x the report: 6 of 9 nesting sites were unwired (decision/enum,
call-microflow/enum, and boundary events on user task, call microflow,
call workflow and wait-for-notification), and only 1 was reported.

Verified: TestAutoBindReachesNestedCallMicroflow covers all 9 sites and
failed on exactly those 6 before the fix; each subtest asserts the
MAIN-flow control in the same run, because auto-bind needs a resolvable
target microflow and a fixture mistake fails identically to the bug.
Control at the mxbuild layer on Mendix 11.10.0, fresh project copy per
run: fix reverted -> the reported 4 errors; fix applied -> 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shard is re-rendered from the entries parsed out of it, so anything in
the file that is not an entry was discarded on the next promote, drop,
resolve or rename. That is right for the parts mxcli owns — the title and
preamble are regenerated so they cannot drift from the shard's identity —
but it also ate YAML frontmatter, which mxcli does not own.

Frontmatter is where every markdown tool in the ecosystem keeps its
per-file metadata: Foam and Obsidian tags, a docs site's nav weight, a
linter's per-file config. A store that eats it cannot be kept in one of
them, and it does so silently: the file stays valid, every entry is still
there, and the loss only shows up when someone next looks. That is the
failure ADR-0005's guard-don't-drop rule exists to prevent, and this writer
was on the wrong side of it.

Preservation goes in SaveShard, the single write choke point, so promote,
replace, drop and rename are all covered by one change. Four details:

  - Recognition is strict, because a loose rule does not miss a block, it
    swallows the document: only an opening fence on the very first line,
    closed by a later line of exactly `---`. An unterminated `---` is left
    alone rather than carrying the whole file forward as opaque text with
    the entries re-emitted beneath it.
  - The module-rename move is the one path the choke point does not cover:
    it writes to a path with no existing file, so the block is read off the
    OLD shard and passed explicitly. Otherwise it would be lost precisely
    when the shard is moved rather than edited.
  - Frontmatter counts toward the shard's cap. `brain show` measures the
    file on disk, so a promote-time check that ignored it would disagree
    with `show` exactly when a shard is near its limit.
  - A shard nobody has annotated is byte-identical — no empty `---` block
    is invented, so existing projects see no diff. That is asserted as a
    control, since inventing one would otherwise let the fix "pass".

Deleting the last entry still deletes the file, frontmatter included: a
shard with no decisions is not a shard, and keeping a husk alive for its
metadata is the accumulation that branch exists to prevent.

Control: with preservation reverted to the old unconditional re-render,
TestFrontmatterSurvivesAPromote and TestFrontmatterSurvivesEveryWritePath
both fail. Verified end to end against the built binary — tags added by
hand survive a later promote, and project.md still opens with its title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qdci88mRumJGXU5ifseAeq
feat(navigation): author offline synchronization (SYNC block)
fix(workflow): auto-wire a nested call-microflow activity (#417)
Make the project brain usable as the channel between sub-agents
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