Idempotent Writes
Re-running an MDL script against a project that is already in sync leaves the
.mpr and mprcontents/ files byte-identical. git status stays clean and
Studio Pro shows no version-control changes, because the write does not happen at
all.
This matters beyond tidiness: without it, git diff cannot answer “did this
script change anything”, two people running the same script commit different
bytes, and a .mxunit merge conflict is not resolvable by hand.
Why a write used to happen anyway
create or replace does not reconcile. It rebuilds the document from the MDL and
overwrites, and every sub-element in that rebuild is a new object with a freshly
random $ID. The stored bytes were a function of the script and a random
source, so no amount of care in the builders could have made them stable.
What mxcli compares
Before writing a unit, mxcli compares the new document against the stored one in
a canonical form: every element $ID is replaced by its index in a
deterministic walk, so a difference in which UUIDs were minted is not a
difference. If the two are canonically equal, the write is skipped and the stored
document — with its existing IDs — stays exactly as it was.
Skipping is safer than writing, not merely cheaper. The stored IDs are the ones every pointer inside that unit already agrees with, and nothing outside the unit can observe them: cross-document references are by qualified name.
The comparison is biased toward writing. If anything cannot be decided, mxcli writes. A redundant write costs you a diff; a wrongly skipped write would lose your edit.
Identity is preserved, not re-minted
A microflow carries a StableId, which Mendix declares as an identifier and
which the build turns into the operation id the browser uses to call that
microflow. mxcli carries the stored value onto the rebuilt document instead of
minting a new one, so re-running a script does not renumber operations in your
deployed model.
A changed document only shows what changed
Skipping the write answers “nothing changed”. When something did change, the
document is still rebuilt from scratch — so without further care, a one-line edit
lands as a wholesale replacement. Editing a single argument of a single
JavaScript action call used to re-mint 36 of a nanoflow’s 37 element
identities; the same edit to a microflow re-minted 21 of 22. Studio Pro’s changes
view and git diff both key on those identities, so a two-line change read as
“the whole document was replaced”. It was also cumulative: changing a value and
changing it back produced a semantically identical document that shared none of
its element IDs with the original.
mxcli now matches the rebuilt document against the stored one element by element
— by shape, and by name where there is one — and puts the stored $ID back on
every element that still corresponds, rewriting each pointer to it in the same
pass. On the case above the diff is now the one changed line, all 37 identities
kept, and a change plus its revert returns to the original bytes exactly.
Inserting or deleting an activity mints IDs only for the elements that are genuinely new; the rest of the flow keeps its identities rather than shifting onto its neighbours’.
Element IDs are not the database’s identity — a GUID is, and that has always
been carried through. See the note in
CLAUDE.md on why the two must
not be confused.
Both engines, every write path
The policy lives in one place (modelsdk/canon) and is applied at the single
write choke point of both the default modelsdk engine and the legacy
engine. Which engine ran is an --engine flag, and it must not be visible in
your diff.
A run of grants is compared as a whole
Most statements write the document they change, and that write is compared with
what is on disk at that moment. A sequence of entity grant / revoke
statements works differently. The run is held in memory and written once, when
the next statement of another kind starts or the script ends, and the write is
compared with what was stored before the run. This matters for a “reset, then
grant” section:
mdl 1;
revoke all on entity Shop.Order from Shop.User;
grant read *, write * on entity Shop.Order to Shop.User;
Written one statement at a time, the grant would be compared with a domain model the revoke had already emptied. It would get a new access rule every run. Judged as one run, a reset that grants back what was there writes nothing, and a reset that changes a rule keeps the identity of every rule it grants back (#872). Statements inside the run read the run’s own writes.
Turning it off
MXCLI_ALWAYS_WRITE=1 mxcli exec script.mdl -p app.mpr
Every write lands, whether or not anything changed. This exists for bisecting a
suspected elision bug (“does it still reproduce if nothing is skipped?”) and is
not a supported option. It disables skipping only — identity is still
preserved, because a forced write that re-minted StableId would change the
deployed app rather than help you debug it.
Verifying it yourself
Run your script twice against a settled project and compare the stored units:
find mprcontents -name '*.mxunit' | sort | xargs sha256sum > before.txt
mxcli exec script.mdl -p app.mpr
find mprcontents -name '*.mxunit' | sort | xargs sha256sum > after.txt
diff before.txt after.txt # expect no output
Two cautions, both of which produce a meaningless zero:
-
Make sure the script is actually re-runnable. A script containing
create moduleorcreate enumerationfails on the second run and writes nothing, so the diff is empty for the wrong reason. Check the run’s output. -
Run the control — and note that it is a control on mtimes, not on content. Since identities are carried,
MXCLI_ALWAYS_WRITE=1produces the same bytes; what it changes is that the files are rewritten at all. Comparestat -c %yinstead ofsha256sumand confirm the timestamps do move. If they do not, nothing ran and the clean result proves nothing.find mprcontents -name '*.mxunit' | sort | xargs stat -c '%Y %n' > t-before.txt MXCLI_ALWAYS_WRITE=1 mxcli exec script.mdl -p app.mpr find mprcontents -name '*.mxunit' | sort | xargs stat -c '%Y %n' > t-after.txt diff t-before.txt t-after.txt # expect output — writes landed
The console tells you the same thing, per document: a statement whose write was
skipped reports Unchanged nanoflow: … rather than Replaced nanoflow: ….
When a run skips several, they collapse into one line rather than one per statement:
Modified entity: MyFirstModule.Od07
Created entity: MyFirstModule.OdNew1
39 documents already in sync (unchanged, not listed)
Every write that actually landed is still named individually — only Unchanged
is counted, because it is the one report that by construction says nothing
happened. A run with exactly one elision prints it in full, so nothing is ever
replaced by a count of one.
For a per-unit view of what would be skipped, scripts/mprsnapshot -canon emits
canonical digests keyed by unit id.
See ADR-0008 for the decision and the measurements behind it.
Writes Are Conditional, and an $ID Is Never Renumbered In Place
Storage does not write a unit whose new content is semantically equal to what
is stored (ADR-0008). The
comparison is on a canonical form — every element $ID replaced by its index in a
containment walk — because a rebuild mints a fresh random $ID per sub-element,
so comparing bytes would skip nothing. The policy lives in modelsdk/canon
(Reconcile) and is called at every write choke point in
modelsdk/mpr/writer_core.go: updateUnit, WriteTransaction.WriteUnit
(codec.Store reaches storage through this one) and — since ako/mxcli#556 —
insertUnit, for the case below.
A delete followed by an insert is a write path too, and it is the one that
hides. Several create or modify handlers are implemented as delete + create
under the preserved unit ID rather than as an update, and an insert has nothing
stored to reconcile against, so the rebuild’s fresh $IDs went straight to disk:
create or modify consumed rest service rewrote 9 element $IDs in a 1,128-byte unit on
every run, forever. deleteUnit now remembers what it removed and insertUnit
reconciles a re-insert against it (carryIdentityFromRemovedUnit). That carry
cannot elide — the row and the file are already gone — so a no-op recreate also
restores _Transaction.LastTransactionID, which both the delete and the insert
bumped; without it the .mpr still showed as modified after every .mxunit had
gone quiet. Prefer an in-place update where the handler can do one: the REST
client’s own fix is to call UpdateConsumedRestService and keep delete+create
only for a folder move, which lives in the unit’s row rather than its contents.
The carry keys on the unit ID, so it does not reach a handler that re-mints
one — and three handlers did, in one week: the REST client (#556), the view
entity’s OQL document (#583) and the layout (ako/mxcli#600). All three took the
same fix, an in-place UpdateRawUnit rather than a replacement. The tell is
cheap and worth reaching for first: ls the .mxunit filenames across two
identical runs. A changed filename is delete+insert and the handler is
wrong; a same filename with different bytes is the codec or a missing carry
and canon is where to look. A replacement also silently reverts the unit’s
ROW, which is how create or replace layout moved a foldered layout back to the
module root on every rewrite — there is no FOLDER clause on the statement, so
the rebuild always names the module root and only an insert applies it.
When something has changed, Reconcile still does not let the rebuild’s fresh
$IDs reach disk: canon.TransplantIDs matches the incoming document against the
stored one element by element (by $Type and shape, by Name where there is one,
LCS-anchored within each list) and puts the stored $ID back on every element
that still corresponds. Without it a one-argument edit re-minted 36 of a nanoflow’s
37 element identities and Studio Pro painted the whole document as changed (#910).
Its correctness bar is lower than it looks and worth knowing: a wrong match only
makes a diff bigger, because every reference is rewritten with the element — the
one real failure is two elements sharing an $ID, which dropCollisions guards.
Three rules follow, and each has already been violated once:
- Never rewrite an element
$IDwithout rewriting every reference to it in the same pass. Pointers are primitive properties holding anelement.ID, notChildProperty, so a containment walk traverses the whole document and never sees one. PR #125 renumbered IDs this way and made projects unopenable (KeyNotFoundExceptionatResolvePostponedProperties). A unit is rewritten wholesale or not at all. The transplant obeys this by substituting over every 16-byte binary in the document rather than a maintained list of pointer properties — any occurrence of one of the document’s element IDs is a reference by definition, the same insight the canonical form rests on. - Adding a write path means wiring it to
canon.Reconcile. A new choke point that writes directly will silently churn while everything else is quiet — the worst kind of inconsistency, because the diff blames the wrong change. - A new document type with an identity property needs a row in
canon.identityFields. It cannot be generated: Mendix’sIsIdentifierlives in the modeler assemblies, not in the reflection datagenerated/metamodelis built from.TestFreshGUIDFieldsHaveAnIdentityDecisioncatches the common case (a property the codec mints fresh on every write) but cannot catch an identity property the codec does not mint. Establish the property’s status the wayStableIdwas — the method table is in ADR-0008.
Elision itself is type-agnostic and covers new document types for free, but it
assumes no binary pointer crosses a unit boundary (measured 0 of 9,910, not
enforced). A document type that references another unit by $ID rather than by
qualified name breaks that assumption and invalidates the argument in ADR-0008.
MXCLI_ALWAYS_WRITE=1 forces every write to land, for bisecting. It does not
disable identity preservation. Any test asserting “nothing changed” must include
a control — otherwise the test passes against a build that never had the fix,
which is exactly how PR #125 shipped green. Note what the control can now be:
since identities are carried, a forced write of an in-sync unit produces the
same bytes, so “flip MXCLI_ALWAYS_WRITE and watch the content change” no
longer distinguishes anything (measured: same sha, mtime moves). Control on the
rebuild instead — encode the document twice and show the raw codec output
differs (TestRebuildChurnsSubElementIDs) — or, from the shell, on mtimes
rather than hashes.
The executor reports which of the two happened: a statement whose unit writes were
all elided prints Unchanged nanoflow: … instead of Replaced nanoflow: …
(ExecContext.ReportMutation, fed by each writer’s WriteStats). The verb is only
downgraded on positive evidence — writes offered, none landed — so a mutation that
never touches unit storage is reported exactly as before.
Several elisions in one run collapse into one line, because that report is the most repeated thing mxcli prints and an agent pays for it on every later model call (a tool result is written into the conversation once and re-read by each one). Measured on a settled 40-statement script: 41 lines / 1,604 B became 2 lines / 177 B. Two rules keep it honest, and each was arrived at by getting it wrong:
- Only
Unchangedcollapses. It is the one verb that by construction reports an absence, so no line a reader would act on is ever replaced by a number — a mixed run still names every real write individually and counts only the rest. Collapsing on volume instead (“after N lines”) would hide real writes in exactly the runs where they matter. - The trigger is how many arrive, not which entry point ran. A lone elision is
printed verbatim, since “1 document already in sync” is worse than the line it
replaces. Gating on “is this a script?” looked equivalent and is not:
-creachesExecuteProgramtoo, becauseexecuteMDLprepends aCONNECTstatement, so a one-liner collapsed to a count of one.
mutationTally (mdl/executor/mutation_tally.go), active only inside a program run.
Every statement that writes is judged this way, not only the ones that report
a document. Security and settings statements report a sentence (“Granted access
on …”, “Set project security level to …”, “Updated configuration …”) and used to
print it unconditionally, so a settled re-run announced writes that never reached
disk (#890). They go through ExecContext.reportWrite, the same rule with a
sentence for the write and Unchanged <subject> for the elision. A move to the
container the document already has writes nothing and says Unchanged.
A run of access-rule statements is the one case where a statement cannot know at
the time: its domain model is held until the run ends (#872). Its reports are held
with it (accessRuleRun, mdl/executor/access_rule_run.go) and printed at the
flush — as written, or as Unchanged when the flush was elided. A
revoke + grant reset that nets to nothing therefore reports nothing written.