Skip to content

Sync ako/mxcli: idempotent writes, describe data-loss fixes, and four upstream issue fixes - #871

Merged
ako merged 23 commits into
mendixlabs:mainfrom
ako:main
Aug 11, 2026
Merged

Sync ako/mxcli: idempotent writes, describe data-loss fixes, and four upstream issue fixes#871
ako merged 23 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

TITLE: Sync ako/mxcli: idempotent writes, describe data-loss fixes, and four upstream issue fixes


Syncs ako/mxcli:main into mendixlabs/mxcli:main — 18 non-merge commits. Everything below was verified against a real project with mxbuild 11.13.0 (and 10.24 where the report named it).

Fixes for filed issues

Projects that could not be opened

  • MDL058create database connection with literal connection string / username / password wrote an .mpr Mendix cannot load (StorageLoadException: … is not a valid ConstantIdentifier). Those are ConstantIdentifier properties with no literal alternative. Now refused at both check and exec, all three reported at once. Not a build error — the loader dies first, so mx check exits 0 on it.

Write-only features (authorable, then unreadable)

The modelsdk reader — the default engine — mapped 32 of 58 microflow action types. Actions could be authored and then not read back, so a describe→edit→exec cycle deleted them.

  • ExecuteDatabaseQueryAction, the eight workflow call actions, and TransformJsonAction / CallExternalAction / RestOperationCallAction are now readable. 44 of 58, and zero of the remainder are authorable.
  • TransformJsonAction was worse than a describe gap: the modelsdk writer had no case either, so transform was written with no action at all and failed CE0008 — unusable on the default engine while the legacy writer handled it.
  • Finishing the workflow readers exposed two further defects, both fixed: call workflow described to a positional form the grammar rejected, and an abort reason doubled its quotes on every round trip.

Idempotent writes (ADR-0008)

A unit whose new content is semantically equal to what is stored is no longer written, so re-running a script against an in-sync project leaves the .mpr byte-identical and Studio Pro shows no version-control changes. Comparison is on a canonical form with element $IDs normalised away; Microflows$Microflow.StableId is carried from the stored document rather than re-minted. Includes a guard so a future document type cannot lose identity preservation silently.

Docs

ADR-0008 and its supporting measurements, a wiki sync on conditional writes and element identity, the dangling-ID-reference failure mode, and description frontmatter on all 16 slash commands (without it they load but never appear in the / menu).

Not included

ako#129 is still open on the fork — it closes the last write-only gap (TransformJsonAction's missing writer, plus the final three readers). The write-only bullet above describes the state after that merges; if this syncs first, transform is still CE0008 upstream.

claude and others added 23 commits August 10, 2026 01:03
… and support SYNCHRONIZE

Upstream mendixlabs#863 reported a nanoflow with a Synchronize activity describing as
`-- Empty action`, with all three activities on its error branch gone and
nothing on stdout or stderr to say so. Two independent defects, plus one
missing feature.

1. The error branch was dropped by an early return, not by the missing action.
   emitActivityStatement returned as soon as a statement rendered as a line
   comment, before errorHandlerFlow was consulted — so EVERY activity the
   reader cannot map lost its handler, as the reporter suspected. The early
   return is itself correct (annotations before a comment make exec fail
   "no viable alternative at input '@position...'"), so it stays and the
   branch is now emitted commented-out. Emitting it live would be worse than
   dropping it: the next exec would run those activities unconditionally in
   the main flow instead of on failure.

2. `-- Empty action` was anonymous. An action with no reader mapping now reads
   back as an UnsupportedAction stand-in carrying the stored $Type and
   ErrorHandlingType, so DESCRIBE names what it could not render. This is not
   one straggler: of 58 MicroflowAction types the modelsdk reader — the
   default engine — maps 31, and 12 of the 27 unmapped are types the legacy
   parser handles, including the shipped ExecuteDatabaseQueryAction.

3. SYNCHRONIZE is now a first-class statement: read, write, grammar, DESCRIBE
   and version gating. `synchronize all | unsynchronized | $Var[, $Var...]`,
   with the usual ON ERROR clause. It is nanoflow-only, so a microflow using
   it is rejected as MDL057 rather than failing the build with CE0009.

The enum values come from the Model SDK's SynchronizationType, not from
Studio Pro's UI: the variable form stores Specific, though the UI says
"Selected object(s)".

Two failures mxbuild caught that the BSON dump could not:
  * CE0011 — a custom error handler needs its own outgoing error flow.
  * CE2004 — a Mendix array's first element is a version marker. Written
    without it a one-element VariableNames reads back empty, so the variable
    was plainly in the document and the build still said none was selected.

Verified on Mendix 11.13.0: all five forms build with 0 errors and
describe→exec→describe is byte-stable.

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

EXECUTE DATABASE QUERY is authorable in MDL and already had a DESCRIBE
formatter, but the modelsdk reader — the default engine — had no case for it.
The activity wrote correctly and read back with a nil Action, so a shipped,
documented feature was write-only: describing the microflow rendered a
placeholder and a describe→edit→exec cycle deleted the query.

The storage $Type is DatabaseConnector$ExecuteDatabaseQueryAction, not
Microflows$ — the action lives in its own sub-metamodel. The reader mirrors
the keys the writer builds directly (Query / DynamicQuery /
OutputVariableName / the two mapping lists) rather than the gen accessors,
which expose the named query as QueryQualifiedName; pinning both sides to one
key set is what makes the round trip mean something.

Verified end to end on Mendix 11.13.0 against a real connection with a
parameterised query and a custom error handler: the statement describes back
verbatim and describe→exec→describe is byte-stable, with the project loading
and no new check errors.

This also supplies the end-to-end demonstration the previous commit could not:
before the reader mapping, the same microflow described as
"-- Unsupported action: DatabaseConnector$ExecuteDatabaseQueryAction" with its
error handler preserved as comments — the mendixlabs#863 fix working on a real project
rather than only in unit tests.

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

A database connection's connection string, username and password are
ConstantIdentifier properties — BY_NAME references to a Constant document,
typed as model.QualifiedName in the metamodel and bound as ByNameRef in gen.
There is no literal alternative.

The grammar offers a string-literal spelling anyway, and the executor passed
it straight through into the reference. The result was a project that will not
open in Studio Pro and that `mx check` cannot even load:

  StorageLoadException: One or more invalid values were detected while loading
  the project: Database Connection 'M.Conn' has an invalid value '' for
  property ConnectionString. The text 'jdbc:postgresql://...' is not a valid
  ConstantIdentifier.

No CE code and no document named, because the failure is in the loader rather
than the validator — so the whole project goes down instead of one unit. Same
class as mendixlabs#854.

Refused now as MDL058, at check AND exec: `check` reporting it does not help a
script that never ran `check`. All three properties are reported at once in a
fixed order, so fixing them does not take three exec runs.

Refusing rather than auto-creating a constant from the literal is deliberate.
Minting a document the author did not ask for is a silent side effect, and for
`password` it would bake a secret into the model as a design-time default —
precisely what the constant indirection exists to avoid.

Found while verifying the ExecuteDatabaseQueryAction reader fix: the
describe→exec→describe diff was byte-stable while the project was unloadable,
which is why the build has to be run and not just the diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
Name unsupported actions, keep their error handlers, and support SYNCHRONIZE (mendixlabs#863)
The PreserveIDs attempt (reverted in f1371f7) rewrote element $IDs
without rewriting the pointers that reference them, which makes a
project unopenable with KeyNotFoundException at
ResolvePostponedProperties. Records the symptom, why only some document
types break, and the verification gap that let it through: a fixture
with no microflow cannot exercise a bug in microflow sequence flows, and
unit tests do not run mx check at all.
…nd the shared unnamed-element key

§4 licenses renumbering "as long as each unit stays internally consistent".
Two new investigation sections give that clause evidence and a cost.

§5 — the constraint has now been violated in practice. PR #125 (reverted)
carried stored element $IDs onto a rebuilt document and left the pointers
referencing them untouched, which makes the project unopenable with
KeyNotFoundException at ResolvePostponedProperties. Microflows and
nanoflows corrupt; pages and navigation survive, which is why surviving
document types are not evidence of safety: DataWidgets contains 0
Microflows$Microflow and Administration 8, per the §4 snapshots, and the
two are updated together.

The part that matters for scheduling Phase 2 is that mxcli's write layer
cannot currently honour the constraint at all: pointers are primitive
properties holding an element.ID, so a walk over child properties never
sees a reference, and there is no way to enumerate reference-valued
properties generically. This argues for import-and-transplant over
anything element-level, stated as a design constraint — never rewrite an
$ID inside a unit being otherwise preserved.

§6 — §4's phantom add/removes from list-index keying are the same
obstacle, and larger than they look: name-or-equal-length-position
matching left 980 of 988 element IDs in one page unmatched, the bulk
being Texts$Text, Forms$Appearance, Forms$ClientTemplate and
Texts$Translation. A stable key for unnamed elements is the one piece of
work this proposal and script idempotence both need.
Two goals (version-control hygiene, marketplace module upgrade) arrived
at the same question and were nearly solved the same wrong way. Records
what the measurements actually support, so the next attempt does not
repeat PR #125.

The decisions: compare before writing and skip when semantically equal;
never renumber an $ID inside a unit being otherwise preserved; make the
marketplace upgrade an import-and-transplant-GUID rather than a merge.
Neither goal needs ID preservation, deterministic derivation, or
reference remapping — those are deferred, not prerequisites.

Adds the measurement the ADR rests on:

  scripts/mprsnapshot -canon   per-unit canonical digests (IDs normalised
                               away, plus a second digest masking fields
                               mxcli regenerates by policy)
  scripts/idempotence-probe.sh runs a script twice over a project copy and
                               classifies every unit as identical /
                               volatile-only / real

On a 27-unit project: 26 identical, 1 volatile-only, 0 real. The one is a
microflow, differing solely in StableId — which microflow_write.go mints
fresh on every write. That is the single policy blocking the approach on
microflow-heavy projects.

Two bugs found by using the probe rather than trusting it, both of the
shape that reads as success: inModule(path, "") tests a prefix and is
false for everything, so an empty filter emitted one unit and an empty
diff looked like "nothing changed"; and keying by path collapsed the
unnamed units that share one, pairing the wrong units between runs.
Keyed by unit id now, which is stable across writes by construction.
Run on mxcli-sudoku (412 units, MPR v2, 30-document idempotent script
set): 386 identical, 26 volatile-only, 0 real differences.

Two things the run establishes beyond the headline zero.

The split is type-determined, not arbitrary. StableId is registered on
Microflows$Microflow only, and exactly the 26 microflows are
volatile-only while the nanoflow, both pages and the navigation document
are identical. Freezing StableId is therefore sufficient for decision 1,
not merely necessary — a stronger claim than the ADR previously made.

The zero is a measurement, not a blind spot. The same probe run against
the withdrawn PreserveIDs binary reports 10 real differences on the same
project — the 9 microflows and 1 nanoflow in that set, page unchanged —
independently reproducing the corrupt/survive split and showing the probe
can report non-zero on the failure this ADR exists to prevent.

Also records the gap honestly: no domain-model documents were covered,
because the domain scripts are not re-runnable, which leaves untested the
one exception the ADR names — entity attribute IDs. Notes what is already
true there (attribute IDs are reused by name since 06a9fac; create or
modify still drops unlisted attributes, now with a warning) so whoever
closes the gap checks rather than assumes.

Measurement and negative control by the mxcli-sudoku session.
Freezing Microflows$Microflow.StableId was the last blocker on decision 1
(skip semantically-equal writes), and mxcli had no description of the field
-- it appears in none of the reflection data, the TypeScript SDK reference,
or the generated metamodel.

Answered from the modeler assemblies and a real build instead:

- Mendix declares it ModelPropertyAttribute("StableId",
  RetentionType.DesignTime) with IsIdentifier = true.
- MicroflowStableIdConversion, a one-time conversion, seeds it once from the
  microflow's storage $ID and thereafter retains it independently -- it is
  the identity that survives the $ID renumbering Studio Pro does freely.
- PackageUtils.RescueStableIDs transplants it by Name on module package
  import, beside RescueDataStorageGuids. Studio Pro's own marketplace path
  is replace-wholesale-then-transplant, which is decision 3.
- Its value escapes the model: RuntimeOperationRegistry derives each
  client-callable microflow's operation id from it. Verified by rebuilding
  the derivation -- 10 of 10 callMicroflow entries in
  deployment/model/operations.json reproduce as
  base64(uuid5(projectId, StableId).bytes_le). com.mendix.webui.jar reads
  that file.

So regenerating it on every write is not incidental churn, and the sudoku
split is explained: only Microflows$Microflow carries the field (16 of 16 in
the fixture; all 13 nanoflows lack it).

Also anchors the .gitignore rule for the stray mprsnapshot build output: as
written it also matched the scripts/mprsnapshot/ source directory, which
silently excluded it from `git add`.

Documentation only -- no behaviour change.
`alter page … set DataSource = $Param` was refused with "unsupported
DataSource type for alter page set: parameter", while the identical retype
through REPLACE succeeded. REPLACE rebuilds the widget through the CREATE PAGE
builder, which has always handled every datasource type — so the capability
existed, just not in the SET path.

Two gaps stacked, neither aware of the other: convertASTDataSource had no
`parameter` case, and the mutator's serializeDataSourceBson had no
*pages.DataViewSource case.

A parameter source is a Forms$DataViewSource with two BY_NAME halves —
EntityRef naming the parameter's entity, SourceVariable naming the parameter —
and Mendix resolves either one it cannot find to null, which is the mendixlabs#854
unopenable-project shape. The entity is not in the statement, so the mutator
resolves it from the container's own Parameters list (the only layer holding
that document) and refuses an unknown parameter name rather than writing an
unresolved reference. Snippet-vs-page is read off the stored parameter's own
$Type, so the two keys cannot be confused.

The unhelpful message was half the report, so the types SET still cannot take
now name REPLACE as the way through.

Verified on Mendix 11.13.0: the reported operation applies, mx check reports 0
errors, and DESCRIBE renders `dataview dvOrder (DataSource: $Order)`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
A Mendix expression navigating an association must name the target entity
fully qualified (`$Issue/MA.Issue_Person/MB.Person/FullName`). mxcli inserts
that step itself, so the author may write the association alone — except it
did not, in two independent ways, both ending in CE0117 with `exec` and
`check --references` silent.

Cross-module: lookupAssociation walked only dm.Associations. An association
whose target lives in another module is a DomainModels$CrossAssociation in a
separate list, where the remote end is the BY_NAME ChildRef — so the lookup
failed, nothing was inserted, and the author's short form was written through
unchanged. This is the third occurrence of that two-list trap, after mendixlabs#854 and
issuetracker #19.

Same-module with the entity named: the skip guard only fired when the next
segment was already qualified. An author writing the bare entity name got the
resolved step inserted IN ADDITION to their own, producing
`MA.Issue_Tag/MA.Tag/Tag/Label` — four segments, as invalid as the three it
was meant to fix. The report covered only the cross-module half; this one
would have survived a fix aimed at it.

All four spellings now converge on the one canonical form: association only,
entity named bare, already qualified, cross-module or not.

Verified on Mendix 11.13.0 with a control built into a single project — short
and fully-qualified side by side gives exactly one CE0117, and dropping the
short one gives 0 errors. After the fix all four build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
Implements ADR-0008 decision 1. Re-running an MDL script against a project
already in sync now leaves the .mpr and mprcontents/ files byte-identical.

Two mechanisms, both in the new modelsdk/canon package so there is one
policy rather than one per engine:

- Canonical comparison. Every element $ID is replaced by its index in a
  deterministic containment walk, so a difference in *which* UUIDs a
  rebuild happened to mint is not a difference. Comparing bytes would skip
  nothing: `create or replace` rebuilds the document and every sub-element
  gets a random $ID, making the stored bytes a function of the script and a
  random source. No knowledge of which properties hold references is
  needed -- the element IDs come from the walk, and any occurrence of one
  of them anywhere is a reference by definition.

- Identity carrying. Microflows$Microflow.StableId is taken from the
  stored document rather than re-minted. It is the one field on a
  microflow whose purpose is not to change: Mendix declares it
  IsIdentifier, seeds it once via a one-time conversion, transplants it on
  module update (PackageUtils.RescueStableIDs), and derives every
  client-callable microflow's operation id from it. Identity is carried
  before the comparison, which is what lets an otherwise-unchanged
  microflow compare equal at all.

Wired at the single write choke point of both engines: modelsdk/mpr
(updateUnit and WriteTransaction.WriteUnit -- codec.Store reaches storage
through the latter, so covering only the former would leave the codec
engine's own writes churning) and sdk/mpr (updateUnit). Which engine ran
is an --engine flag and must not be visible in a user's diff.

Every failure path falls through to writing. A false "different" costs a
redundant write, which is the previous behaviour; a false "equal" would
silently discard the user's intent. Skipping is also safer than writing:
the stored IDs are the ones every pointer inside that unit already agrees
with, which is exactly what the reverted PR #125 broke by rewriting them.

MXCLI_ALWAYS_WRITE=1 disables elision for bisecting. It deliberately does
not disable identity preservation -- a forced write that re-minted
StableId would renumber the deployed model's operation ids, which is a
change to the app rather than a debugging aid.

Verified end to end through the built CLI on a 371-unit MPR v2 project: a
settled re-run changes 0 units, and the same measurement with elision
disabled changes 2 (the two microflows the script writes), so the
measurement can report non-zero. `mx check` reports 0 errors both on the
elided project and on the one where identity-patched writes landed. Every
test carries that control, because a test that only passes against fixed
code has not been shown to detect anything.
…ently

No-op elision is type-agnostic -- it compares canonical forms of raw BSON and
knows nothing about document types, so a new one is covered the day it is
added, and anything it cannot parse falls through to writing. Identity
preservation is not: canon.identityFields is hand-maintained and cannot be
generated, because Mendix's IsIdentifier flag lives in the modeler assemblies
and not in the reflection data generated/metamodel is built from.

The failure mode for a future document type is therefore silent -- the type
gets an identity property, nobody adds a row, and elision quietly stops
working for it. No error, no failing test.

TestFreshGUIDFieldsHaveAnIdentityDecision closes the most likely path. A
property registered as a codec FreshGUIDField is minted anew on every write,
so by construction it makes a document differ from itself; the guard fails
unless each one is recorded as either identity (carried) or deliberate churn
(waived with a reason naming what reads the value). Verified to detect drift
by removing the Microflows$Microflow row and confirming the failure, rather
than only that it passes.

It is a guard, not a proof: it cannot catch an identity property the encoder
does not mint fresh. ADR-0008 now says so, and also records the invariant
elision itself rests on -- that no binary pointer crosses a unit boundary
(measured 0 of 9,910, not enforced). A future type referencing another unit
by $ID rather than qualified name would let an elided unit's discarded IDs
dangle from a unit that was written, which is PR #125's failure arriving from
the opposite direction.

Adds codec.FreshGUIDRegistrations and canon.IdentityFields as the accessors
the guard needs. No behaviour change.
…ge must keep

Follow-up to the compare-and-skip implementation, after it was confirmed
green on mxcli-sudoku.

- ADR-0008: records the post-implementation confirmation, closing the loop
  the pre-implementation measurement opened (the 26 volatile-only units were
  volatile only because of StableId). Also states plainly that a claim about
  idempotence needs the MXCLI_ALWAYS_WRITE=1 control alongside it, citing the
  measurement taken during implementation that reported zero changed units
  purely because the script aborted on its second run.
- ADR index: 0008 was missing from the table in docs/13-decisions/README.md.
- CLAUDE.md: new always-in-context section covering the three rules a future
  change has to keep -- never rewrite an element $ID without rewriting the
  references in the same pass, wire any new write path to canon.Reconcile,
  and give a new document type with an identity property a row in
  identityFields. Names the invariant elision rests on (no binary pointer
  crosses a unit boundary; measured, not enforced) and the requirement that
  a "nothing changed" test carry its control. Adds the feature to the
  implementation-status list.
- check-syntax skill (synced to user projects): re-running a settled script
  changes nothing, so `git status` is now a reliable answer to "did this do
  anything".

Docs only.
…fety claim

Triggered by ADR-0008 landing and compare-and-skip shipping.

architecture/mpr-read-write.md
- Frames the two engines and the single updateUnit choke point each funnels
  writes through, which is what makes a cross-cutting write policy
  expressible without touching a serializer.
- Adds the conditional-write model: why byte comparison would skip nothing,
  and why the canonical form needs no knowledge of which properties hold
  references.
- Corrects a pre-existing inaccuracy. The page claimed v2 writes stage
  through WriteTransaction with temp files; in sdk/mpr that type has no
  callers and the ordinary path is a direct WriteFile. Temp-file staging and
  the _Transaction bump are the modelsdk engine. Caught only because the
  skill requires re-reading sources rather than synthesising from memory.
- Sources: added sdk/mpr/writer_units.go (where updateUnit actually lives)
  and the modelsdk/canon/ADR set; dropped sdk/mpr/writer_widgets.go, since
  the BSON-convention claim is grounded in parser.go and widget specifics
  belong to the widget-engine page.

models/element-identity.md (new, added to the seed table in the same run)
- $ID / GUID / StableId as three identifiers with different scopes and
  lifetimes; the unit as identity boundary; why pointers are invisible to a
  containment walk and why "the pages still load" is not evidence.
- Framing only: the decision and measurements stay in ADR-0008, the rules in
  CLAUDE.md, the field table in source.

Two SYNC_LOG rows appended listing what was actually read. All wiki-links and
relative source links resolve. Docs only.
mxcli-formula1 -- a multi-app solution using published OData services,
external entities and the external database connector -- came back green.

Recorded for what it actually adds: coverage, not another zero. Every
measurement behind this ADR until now came from projects whose documents were
microflows, nanoflows, pages and navigation, and it was open whether elision
holds for document types nobody had exercised.

It bears most on the invariant elision rests on -- that no binary pointer
crosses a unit boundary. A multi-app solution with external entities and OData
contracts is the shape where a cross-document binary reference would be most
likely to exist, so its absence there says more than another microflow-heavy
project would. Corroboration, not proof: a passing project shows no observed
violation, not that none can occur.

Deliberately not written up as an idempotence measurement. It is not
established that the MXCLI_ALWAYS_WRITE=1 control was taken alongside it, and
by this ADR's own standard a clean result without its control does not qualify.
…ping

A SNIPPETCALL for a parameterised snippet had no working spelling. Omitting
Params was refused by mxcli; supplying `params: {X: $currentObject}` was
accepted and then failed the build with CE0115 "The arguments that are passed
to snippet … do not match the expected parameters and need to be refreshed."
Studio Pro's own "Refresh snippet parameters" then deleted what mxcli wrote.

mxbuild shows the defect is narrower than reported: passing a REAL page
parameter always built at 0 errors. Only `$currentObject` failed, because it
was translated into

  Forms$PageVariable{ PageParameter: "currentObject" }

a by-name reference to a page parameter that does not exist.

"Satisfied by the enclosing data context" is not a variable in Mendix — it is
the ABSENCE of a mapping. That is why Studio Pro emits an empty
ParameterMappings here, and why its refresh removes the invented entry.

So `$currentObject` now emits no mapping, and Params may be omitted entirely —
but only when the surrounding data context is the parameter's own entity. A
context of a different entity still gets the guidance, since nothing there can
satisfy the parameter and a build error later would be worse. Naming a real
variable still produces a real mapping.

Verified on Mendix 11.13.0: all three forms build at 0 errors in one project,
with empty ParameterMappings for the two context-satisfied pages and a real
mapping for the page passing its own parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
call workflow, get workflow data / workflows / activity records, open, lock
and unlock workflow, and workflow operation were all authorable in MDL and all
had DESCRIBE formatters — but the modelsdk reader, the default engine, had no
case for any of them. Each read back with a nil Action, so a shipped feature
was write-only: DESCRIBE showed a placeholder and a describe→edit→exec cycle
deleted the activity.

The reader mirrors the keys workflowMicroflowActionToGen writes rather than
the gen accessors, which disagree in one place per "get" action: gen binds the
result variable as VariableName, the model stores OutputVariableName. A reader
written against the accessors returns "" and the variable disappears — which a
reader-only test seeded with hand-written BSON would never notice, so these
are round trips that pin reader and writer against each other. All ten fail
against the pre-fix reader with the nil-Action symptom.

Finishing the reader exposed two defects that were invisible while nothing
rendered at all, both now fixed:

  * `call workflow X ($Ctx)` — the model stores only the context variable,
    never the parameter's name, so DESCRIBE can only emit the positional form,
    which the grammar did not accept. Describe output would not parse back in.
    A workflow has exactly one context parameter, so that form is unambiguous
    and is now accepted alongside the named one.

  * `workflow operation abort … reason '…'` — the reason lands in a
    StringTemplate's Text, which is literal text, but the expression string was
    stored with its quotes. Mendix rendered them at runtime and DESCRIBE
    re-quoted what it read, so each round trip doubled the quoting without
    bound.

Verified on Mendix 11.13.0 against a real workflow: every form builds at
0 errors and describe→exec→describe is stable.

Not fixed here: `lock/unlock workflow all` writes an activity mxbuild rejects
with CE1825 "The 'Workflow' property is required". A lock always needs a
specific workflow definition, and writing an empty selection does not satisfy
it either — so the MDL form has no valid representation. Recorded in the
symptom table and the example; it needs its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
None of the 16 command files carried YAML frontmatter — each started with a
bare `# heading` — so nothing supplied the `description` a client uses to list
them. The commands were always loadable and invocable by full name; they just
did not appear in the `/` menu, which reads as "not installed".

Adds `description` to all 16 and `argument-hint` where a command takes
arguments, across both namespaces. `mendix/` is embedded into `mxcli init`
output, so user projects get the same listing.

No behaviour change: the command bodies are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
Read the eight workflow call actions back (describe was write-only)
@ako
ako merged commit b6b790a into mendixlabs:main Aug 11, 2026
4 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