Skip to content

Sync ako/mxcli: rules as a document type, workflow data-loss fixes, and the test-annotation framework - #952

Merged
ako merged 57 commits into
mendixlabs:mainfrom
ako:main
Aug 21, 2026
Merged

Sync ako/mxcli: rules as a document type, workflow data-loss fixes, and the test-annotation framework#952
ako merged 57 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

32 commits since #942. Two feature areas — rules as a first-class document type, and the test-annotation framework — plus a run of workflow fixes where the common thread is silent data loss that no static gate reported.

Rules — a new document type, end to end

  • Read: LIST RULES / DESCRIBE RULE, with the document shape pinned against Studio Pro before anything was written.
  • Author: CREATE / DROP / MOVE RULE, plus the restriction validator. A rule is handled like a nanoflow — settled by measurement, not analogy.
  • Catalog: a rule is a catalog object, and its body is walked for references, so show references to sees it.
  • Decision rule calls were being dropped. A decision that called a rule had its condition written as nothing. Now written, and the call is recorded as a reference.
  • Expression checking: a rule or microflow call is refused where a Mendix expression cannot have one, and a qualified call parses instead of its paren being reported as junk.

Workflows — five fixes, all silent data loss

Test framework

  • @setup did nothing — the annotation was parsed and ignored. Now implemented.
  • A file header could swallow the first test; count() assertions were not evaluated; an annotation was read even when it did not open its line; @expect on a test expecting an exception is now refused.
  • The test format documentation now describes what mxcli actually runs.

Domain model, pages, security

claude and others added 30 commits August 21, 2026 07:02
`if Module.SomeRule(param = $x) then` was stored with no Condition at all
on the modelsdk (default) engine. Measured on mxbuild 11.13.0:

  default engine       → [CE0080] "The 'Condition' property is required."
                         at Decision 'Sample.Rule_IsActive(IsActive = $IsActive)'
  MXCLI_ENGINE=legacy  → 0 errors, same script, same project
  after this change    → 0 errors, BSON identical to legacy's modulo key order

Two defects, one behind the other.

splitConditionToGen handled only ExpressionSplitCondition and fell through
to `default: return nil`; the caller then skipped SetSplitCondition
entirely, so the ExclusiveSplit was written with only its caption. The read
side had been implemented for mendixlabs#723 — DESCRIBE renders the broken document
as `if true then` while the caption still shows the call — and that
asymmetry is why the corruption was silent, and why re-applying an
identical CREATE OR MODIFY over a Studio Pro-authored microflow turned a
clean project into a broken one.

modelsdk/gen binds Microflows$RuleCall.Rule to the BSON key "Rule", where
Mendix stores "Microflow" (rules share the microflow namespace).
generated/metamodel (`json:"microflow"`), sdk/mpr/writer_microflow.go and
the keyaudit ledger all agree. Fixing only the missing case would have
written a reference Mendix never reads and produced the same CE0080 for a
different reason, so this is a STORAGE-NAME OVERRIDE applied to both the
encode and the decode literal, with the ledger row struck off.

The reported "works on Mendix 10, fails on 11" is an engine difference, not
a version one: the microflow write path branches only on major >= 10 / <= 9,
so 10.24 and 11.12 take identical code.

Controls: reverting the writer case alone fails
TestMicroflowRoundTrip_RuleSplitCondition with a nil condition; reverting
the storage-name override alone fails
TestRuleSplitConditionUsesMicroflowStorageKey while the round-trip test
still passes — which is why the storage key is asserted separately.

Refs mendixlabs#939

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

A Mendix expression has no user-callable functions — its library is built-in
and unqualified — so `Module.Name(arg = $x)` in a value position is not an
expression at all. mxcli wrote the literal text on both engines. Measured on
mxbuild 11.13.0:

  declare $b Boolean = Sample.Rule_IsActive(IsActive = $IsActive);
    → [CE0117] "Error(s) in expression." at Create variable activity
  declare $n Integer = Sample.MF_Callee(N = $N);
    → [CE0117] — a microflow is no more callable there than a rule

Both have a working spelling, which is what makes this worth a diagnostic
rather than a shrug: a microflow or Java action is `$r = CALL MICROFLOW …`,
and a rule belongs in a decision.

The check splits by what it needs. Whether a qualified call is illegal in a
given position is project-less — no built-in Mendix expression function has a
dot in its name — so MDL066 reports it from the AST alone, and exec's
pre-flight gate refuses the script. The one legal home for a bare qualified
call is a decision condition, which mxcli stores as a RuleSplitCondition, so
MDL066 exempts `if` and nothing else; a while condition has no rule-split form
and is flagged. (While bodies were not walked at all before, so nothing inside
one was checked.)

Whether the name in a decision resolves to a RULE needs the backend, so that
half lives in the flow builder: a qualified call that is not a rule used to
fall back to an ExpressionSplitCondition holding the call text — valid-looking
MDL that is CE0117 — and is now refused with the CALL MICROFLOW spelling.

Controls: with the MDL066 walk stubbed out, the value-position and while tests
report 0 violations; the builder test pairs the refusal with a real rule that
must still produce a RuleSplitCondition.

Refs mendixlabs#939

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
…en as junk

`mxcli check -p` reported

  Unexpected token after expression — the expression appears incomplete or
  malformed (possible missing space between keywords)
    → Check for glued keywords such as 'emptyor' …

with an empty location, on a script that parses fine without -p and executes
correctly. exprcheck did not model `Module.Name(...)`: the qualified name
parsed as a member path and the `(` was left on the stream, which Parse
reports as leftover.

It fired on the VALID decision form as much as the invalid ones, so it carried
no signal, named a typo that was not there, and masked the real defect in the
same script (mendixlabs#939). parseQualifiedCall now consumes the argument list.

UnknownFunctionCalls skips the new node. It feeds MDL044's did-you-mean, and a
qualified name has no near built-in, so it would emit nonsense — and it would
fire on the legal decision form. MDL066 owns that diagnostic and knows which
positions are legal.

Control: with parseQualifiedCall reverted, the test reports the trailing-token
hint and a *QNameExpr. A bare qualified name with no parentheses must still
parse as a QNameExpr (enumeration/entity reference), which is pinned too.

Refs mendixlabs#939

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
`show callers of Module.SomeRule` reported none even with the condition
correctly stored, and `show references to` was empty. A rule is not an
activity — Mendix evaluates one only as an ExclusiveSplit's condition — and
the reference extractor walked only action activities, so the edge was never
emitted. mendixlabs#939's reporter read that as "the reference never resolves".

collectRuleCalls walks the object collection for a RuleSplitCondition,
recursing into LoopedActivity bodies as collectActionActivities does, and
emits a `call` edge into CATALOG.REFS. Measured on a real 11.13 project:
`show callers of Sample.Rule_IsActive` goes from "(no callers found)" to the
calling microflow.

Rules are still not catalog OBJECTS, so a microflow called only from inside a
rule's body remains dead code to QUAL004 / GRAPH_DEAD_ASSETS. Adding the
object type without also indexing rule bodies would report every rule itself
as dead, so it needs both halves at once — noted in the repro and the symptom
table rather than half-done here.

Also adds the mendixlabs#939 reproduction script and two symptom-table rows.

Refs mendixlabs#939

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

Two upstream issues have now landed on the consequences of mxcli not
supporting Microflows$Rule rather than on the gap itself, and both were fixed
one symptom at a time: mendixlabs#723 §A4 (IsRule unimplemented → CE0117) and mendixlabs#939 (no
write path → CE0080, plus three secondary symptoms with their own causes).
The underlying state is unchanged, so neither is the last one.

Surveyed against a real 11.13 project carrying a rule: LIST FOLDERS shows it
(for free, via ListDocumentUnits + DocumentKind) and a decision can call it,
but SHOW/DESCRIBE/CREATE/ALTER/DROP/MOVE do not parse, it is absent from
ast.MoveDocumentTypeByKeyword's 31 doctypes and from CATALOG.OBJECTS, and its
body is never walked — so a microflow called only from inside a rule reads as
dead code.

The proposal's shape: a rule is a third flow flavour. createNanoflowStatement
is already a verbatim mirror of createMicroflowStatement sharing the body
grammar, the flow builder and the describer, differing by a $Type, a
flowBuilder flag and a disallowed-activity list. A rule is the same
relationship with Mendix's own restriction list, so almost none of the work is
new.

Four slices, the first two read-only. Slice 3 (authoring) is blocked on a
Studio Pro-authored rule: the document shape here is verified by construction
— a synthetic rule keeping the ten keys initRule declares checks clean on
mxbuild 11.13 — and mxbuild tolerating it proves little, since Studio Pro is
stricter. The open measurement is the canvas parameter's $Type, where the
metamodel lists both Microflows$MicroflowParameter and Microflows$RuleParameter
and real documents show the metamodel's split is not what storage does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
…endixlabs#945)

ALTER WORKFLOW over --mcp wrote CE0495 "Duplicate name" into live projects
while reporting success, because two independent gaps lined up.

A CALL MICROFLOW activity is named after its target microflow, so two calls
to the same microflow collide before anything inspects them. The file
backends deduplicate in wfmutator; the MCP backend had no equivalent and sent
the derived name verbatim. Measured live: PED accepts the duplicate with
SUCCESS and does not auto-rename, and it refuses a later `set` on the
activity's /name ("Element type does not support renaming") — so the name has
to be right at add time.

Nothing caught it afterwards either. mcpWorkflowMutator.Save() early-returned
when only activity ops had run, so ped_check_errors — the only thing that
sees a consistency error — was never called.

Extract the naming policy into mdl/backend/wfnames, shared by both file
backends and MCP: a taken-set rather than a seen-count (counting re-collides
when the workflow already holds the suffixed name), recursing into outcome
and boundary-event sub-flows because uniqueness is workflow-wide. The MCP
side seeds it from names collected on the walk resolve() already performs, so
it costs no extra PED round-trips, and it is wired into all six
activity-adding ops.

CREATE / CREATE OR REPLACE were never affected — the executor deduplicates
for every backend.

Wiring the validation in exposed a second defect: ped_check_errors reads
Studio Pro's background error list, which lags the write in both directions.
Measured at a 20ms poll interval, 26 samples across three write-load levels:
an error becomes visible 77-115ms after the write and stops being shown
75-128ms after the fix — one symmetric debounce, no growth under load. (An
earlier 170-350ms reading was a polling-granularity artifact.) Asked
immediately, the check misses a real error and equally reports one a
just-applied op has already cleared, failing a valid statement.

Backend.pedCheckDocument now owns the pacing for every call site: wait
settleDelay, then re-ask across settleWindow while the answer stays clean,
short-circuiting on an error. It lives in the shared helper so a new write
path cannot forget it; no backend operation validates more than once.

Verified live with the immediate check as control, 6 trials per direction:
immediate missed the real error 6/6 and falsely failed on a cleared transient
6/6; settled was 0/6 on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ako/TestApp (Mendix 11.13.0) carries two Studio Pro-authored rules — Boolean
return with a String parameter, and enumeration return with an entity
parameter — which answers both questions the authoring slice was blocked on.

The canvas parameter is Microflows$MicroflowParameter carrying the
ParameterObject shape, identical to a microflow's; Microflows$RuleParameter
appears in neither document, so it is an SDK-side name with no storage
counterpart and the rule writer reuses microflowParameterToGen unchanged.

ReturnType is NOT written. gen declares it and generated/metamodel 11.6.0 does
not; Studio Pro 11.13 writes only MicroflowReturnType. So it is not an
Interval/IntervalType-style carry-through — there is nothing to carry, and
mxcli must not invent it.

Both rules store exactly ten properties and nothing else. That the
microflow-only keys are absent is now measured rather than inferred from the
type definition: a Studio Pro microflow in the same app stores 19, including
AllowedModuleRoles, StableId, Url and the concurrency group.

Also records two deltas found while diffing, both general to microflows rather
than rule-specific and neither with a known symptom: Studio Pro writes a bare
marker for an unconditional flow's CaseValues where mxcli writes an explicit
NoCase, and stores the connection indices as int64 where mxcli writes int32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
… count() assertions

Two defects in .test.mdl handling, both of which made a suite report green
while asserting less than it looked like it did.

Bug 1 — a file-level `/** … */` header made the first test vanish: not a
pass, not a fail, not an error, with every later test renumbering over it.
A header is not separated from the test below it by a '/', so both land in
one chunk, and extractDocAndBody took the chunk's FIRST doc comment as the
test's own. It carried no @test, so the whole chunk — real test included —
was skipped. The delimiters were found by substring search with no notion of
comment or string state, which is why the documented workaround (a `--`
header) re-triggered the fusion the moment that header's prose spelled `/**`
and `*/` out.

The chunk is now scanned rather than searched: the doc is the last comment of
the leading run, the body starts after it, and a '/' inside a line comment or
a string literal is text. Two @test doc comments in one chunk are refused by
name instead of resolved — either resolution runs one and silently drops the
other, which is the bug. Tests are numbered as tests, not as chunks (a
skipped header used to leave a file's only test reporting as test_2), and
each test's line travels with its chunk instead of being recovered by
searching the file for its first 20 bytes, which found the wrong chunk when
two started alike and panicked on a chunk shorter than that.

Bug 2 — `@expect count($Var) = N` on a bare retrieve block reported PASS
unconditionally, for any N, against an empty table. count() is not a Mendix
expression function: counting a list is an Aggregate list activity, so the
condition could never be compiled into the decision that evaluates the
assertion. On 0.18.0 the annotation was dropped during parsing, and a test
with no assertions passes as long as its body does not throw. Since 0bf9382
it is an ERROR — fail-closed, but it left the natural spelling unusable.

The count is now lifted into the activity an author would write by hand,
`$mxtest_count_X = COUNT($X);` ahead of the decision, in both the endpoint
and the monolithic runner; in the latter the generated variable is renamed
per test like the body's own, or two tests counting same-named lists declare
it twice. sum/average/minimum/maximum aggregate an attribute an assertion
cannot supply, so they stay refused — now naming the helper-microflow
workaround rather than reporting that they are not expression functions.

Verified at the Mendix layer, not just the parser's: the generated flows were
exec'd into a real 11.13.0 project and mx check run against a pristine-copy
baseline (1 pre-existing error either side, none in the generated flows).
Controls: the pre-fix binary finds 2 of the repro file's 4 tests, and
stubbing out the aggregate branch restores "count() is not a Mendix
expression function".

Fixes mendixlabs#927

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

fix(mcp): deduplicate workflow activity names and settle validation (mendixlabs#945)
modelsdk/gen bound Security$ProjectSecurity's two user-role references as
"AdminUserRoleName" and "GuestUserRoleName". Every Studio Pro document stores
them without the suffix, and generated/metamodel agrees (`json:"adminUserRole"`,
`json:"guestUserRole"`). Both were on the known-mismatch ledger, unfixed.

The read half was live and engine-dependent: SHOW PROJECT SECURITY printed no
Guest User Role on the modelsdk engine while the legacy parser — which reads the
right key — printed it for the same project. The same blind spot reached the
Starlark lint API, where the documented anonymous_user_role and role.is_anonymous
fields resolved to "" and never matched, so a rule asking what anonymous visitors
can read found nothing.

The write half had not fired yet but was the more serious one. gen preserved the
stored GuestUserRole as an unknown-key passthrough, so existing writes lost
nothing; calling the setter would have added a second key beside it, which
Studio Pro refuses to open even though mxbuild tolerates it.

A property.Primitive decodes and encodes under one bound name, so one literal per
property covers both directions.

Control: with the role rewritten under the pre-fix key, mx check 11.13 reports
CE0133 ("No user role for anonymous users selected...") — 6 errors against 5 with
the storage name.

Refs mendixlabs#924

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
Anonymous access was readable but not writable, so an app with a public area
could not be built unattended — every headless run ended with a manual step in
Studio Pro.

    ALTER PROJECT SECURITY GUEST ACCESS ON ROLE <UserRole>;
    ALTER PROJECT SECURITY GUEST ACCESS ON;    -- role already stored
    ALTER PROJECT SECURITY GUEST ACCESS OFF;

Wired the way DEMO USERS ON|OFF already was: same AST node, same unit, same write
choke point, both engines.

Two mxbuild 11.13 behaviours shape the syntax, and they pull in opposite
directions. Guest access on with an empty role is CE0133 (6 errors against 5), so
the role cannot be optional in effect — but a role that does not exist builds
with the same count as a valid one, leaving anonymous visitors with no access at
all. So ROLE is optional in the grammar, because a stored role satisfies CE0133
and re-enabling should not force a retype, while the executor refuses ON when
neither source supplies one and validates the name itself against the project's
user roles.

OFF keeps the stored role: guest-off-with-role is valid Mendix, and clearing it
would lose the operator's choice on a toggle.

No check-time counterpart in validate_security.go, deliberately — whether a bare
ON is legal depends on what the project stores, which mxcli check cannot see.

Closes mendixlabs#924

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
TestApp now places both rules in a folder, which settles the placement question
by measurement: the rule's unit row is ContainmentName "Documents" with
ContainerID pointing at the folder unit, itself Folders under the module —
identical to a microflow. Nothing about a rule's placement is special.

That shrinks the plan twice. LIST FOLDERS already renders foldered rules with
no change, because the ListDocumentUnits walk is containment-generic; and the
MOVE/FOLDER work becomes one entry in ast.MoveDocumentTypeByKeyword rather than
a placement implementation.

Still outstanding, and still not blocking: TestApp contains no
Microflows$RuleSplitCondition. Rules.MicroflowUsingRule exists but is empty
(start to end, no decision), so the shape mendixlabs#939 writes remains validated against
the legacy engine's output rather than against Studio Pro's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
The annotation patterns were unanchored, so `@expect` was matched anywhere in
a doc-comment line rather than at its start. Prose that quotes a tag became a
real annotation: a sentence reading "`@expect $x = 1` in a sentence" gave the
test that assertion, and "@cleanup none would apply here" changed its cleanup
strategy. The invented assertion usually fails to compile, so the test reports
an ERROR whose message quotes the prose.

Found while writing the mendixlabs#927 repro file — its header explained the bug, and
explaining it created an assertion that then errored against a test nobody had
written.

An annotation is a javadoc tag, and a tag opens its line. Every pattern is now
anchored; the leading `*` and its indentation are stripped before they run, so
the real spellings are unaffected at any indentation, including the one-line
`/** @test x */` form — which the control test pins, since a fix that simply
stopped reading annotations would otherwise pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
A test carrying both @throws and @expect reported making two assertions and
made one. @throws selects a different generated shape in both generators — the
verdict starts as a failure and only the error handler clears it — and neither
emits the @expect checks into it, so the assertion could not fail whatever it
claimed, while AssertionCount still counted it.

This is the same silent-absence class as a dropped @expect or an unimplemented
@verify, in the one combination where the assertion cannot be made to work: the
body was expected not to produce a result to assert on. So it is refused rather
than quietly ignored — the expects become assertion errors, which makes the test
an ERROR and gives it no microflow. The @throws itself still stands; it is the
assertion the test can actually make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
The three testing pages documented a format the implementation has never had.
They showed `.test.mdl` tests as `-- @test` line comments over DDL statements
with `-- @expect 0 errors` for an outcome, and `.test.md` as plain ```sql
blocks. mxcli reads javadoc blocks separated by `/`, `@expect` is any Mendix
condition over the body's variables, and the markdown format needs an
`mdl-test` fence. Someone following the pages wrote a file with no tests in it
and got "Found 0 test(s)" with nothing to explain why.

test-annotations.md and test-formats.md are rewritten against the parser and
the runner; testing.md's framing and example follow. The annotation reference
now covers @verify, @throws and @cleanup, which were missing entirely, and
carries the rules that are only visible when you hit them: one-row-one-column
for @verify, its refusal under the rollback default, count() as the one
aggregate an assertion can make, a tag being read only when it opens its line,
and @expect being refused alongside @throws.

@setup is deliberately not documented: it is parsed and read by nothing.

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

TestApp gained a decision calling Rules.Rule1, so the RuleSplitCondition shape
is no longer validated only against the legacy engine's output. Studio Pro
stores the reference under the Microflow key with a marker-2 ParameterMappings
list and a fully-qualified Parameter — every element of which is what the mendixlabs#939
fix writes, giving independent confirmation of the initRuleCall override.

Round-tripped it too: DESCRIBE renders the rule call and show callers lists the
microflow (the read and reference paths exercised against a Studio Pro document
for the first time), and re-executing that describe output leaves mx check at 0
errors against a 0-error baseline, with the SplitCondition block absent from
the before/after diff entirely.

The diff's remaining entries are all pre-existing and general to microflows —
connection-index widths, CaseValues shape, bezier control vectors a @Curve
annotation did not carry, and object ordering. Recorded so the next person
diffing a rule does not attribute them to this feature.

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

A rule gets its own semantic type mirroring microflows.Nanoflow (already a
distinct struct, not an alias of Microflow), its own statements, and its own
listing: SHOW/LIST MICROFLOWS stays microflow-only, with SHOW/LIST RULES beside
it via the existing showOrList rule so both spellings work.

One place the nanoflow parallel stops: there is no GRANT EXECUTE ON RULE. Both
Studio Pro reference rules store no AllowedModuleRoles, because a rule is not
independently callable and so has no module-role security to grant.

Records what 'like a nanoflow' buys: a rule's surface is much smaller, since a
nanoflow is reachable from pages, navigation and widget actions while a rule is
reachable only from a decision — so the page grammar, widgetobj, sdk/pages and
gen/navigation need learn nothing about rules, while microflowBody, the flow
builder, the describer and the validator are shared exactly as the nanoflow
shares them.

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

Dropping an attribute reported success but left its validation rule behind,
so the project failed to build:

  [error] [CE1613] "The selected attribute 'DropAttr.Account.AccountNumber'
  no longer exists." at Validation rule of entity 'DropAttr.Account'

Two independent bugs on one statement, each of which hid the other.

1. The executor's cleanup never matched. A validation rule and an access
   rule's MemberAccess reference their attribute by BY_NAME qualified name
   ("Mod.Ent.Attr"), while the cleanup compared the dropped attribute's
   element ID. Both arrive in the same model.ID field, so the comparison
   matched nothing and every rule was kept. Which field carries the name
   also varies by engine: legacy fills AttributeID and AttributeName,
   modelsdk fills only AttributeName. The check now accepts either form in
   either field. Index references really are element IDs
   (AttributePointer), which is why that one path always worked.

2. With (1) fixed the rule was removed in memory and still not written.
   entityToGen only appends to a child list when it is non-empty, so a list
   the update empties stays "clean" and the codec passes the stored raw
   bytes through. The Indexes list already had a one-off fix for this
   (ledger #39); ValidationRules, Attributes, AccessRules and EventHandlers
   are now covered by one loop instead of being rediscovered one error code
   at a time.

The Attributes case was worse than a dangling reference: dropping an
entity's only attribute reported success and wrote nothing at all.

Both bugs only bite when the update removes the LAST member of a list --
removing one of two dirties the list and works fine -- which is why they
survived: any two-member fixture passes against the broken code. The new
tests use exactly one member each, and the two-member case is kept as a
positive control.

Verified on a real 11.13.0 project: mx check goes 1 error -> 0, and with
the member-access half fixed the downstream ReconcileMemberAccesses pass
finds nothing left to repair. Controls: reverting either fix makes the
corresponding test fail with the reported symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
A Microflows$Rule was the one document type mxcli could reference but not read.
IsRule answered a yes/no question about a name; nothing could list a rule, show
its signature, or render its body, so a rule was invisible to every surface
except the decision that called it.

Slice 1 of PROPOSAL_rule_documents: read-only, no new BSON writes. A rule is
handled the way a nanoflow is — its own semantic type, its own listing — so
SHOW/LIST MICROFLOWS still lists microflows only.

microflows.Rule already existed as an unused stub whose comment claimed the
return type is "always boolean". Rules.Rule2 in the reference app returns an
enumeration, so that is now stated correctly, and the struct carries the ten
properties a rule document actually stores, measured against two Studio
Pro-authored rules.

Both engines read it: ruleFromGen mirrors nanoflowFromGen, and the legacy
parseRule mirrors parseNanoflow. Neither reads AllowedModuleRoles (a rule is
not independently callable, so it has no module-role security) nor gen's
ReturnType sibling, which Studio Pro does not write and generated/metamodel
does not list — a pre-7 legacy property with nothing to carry through.

DESCRIBE RULE renders re-executable MDL by wrapping the body in a Microflow and
reusing formatMicroflowActivities, exactly as the nanoflow describer does.

Verified against the Studio Pro rules rather than only against fixtures: LIST
RULES reports both with their folder, parameter count and return type, and
DESCRIBE renders the Boolean rule with its String parameter and the enumeration
rule with its entity parameter. The encode test asserts the ten keys a rule
carries and the absence of the nine it does not, with a control so it cannot
pass against an empty document; both read tests were confirmed to fail when
ruleFromGen drops the return type or ReturnVariableName.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
…abs#944)

ALTER WORKFLOW ... REPLACE ACTIVITY X WITH <activity named X> renamed the
activity to X_2, and re-running the same script compounded it: X_2_2,
X_2_2_2, and so on without bound. A no-op replace renamed too. Nothing
surfaced it — not mxcli check, not exec's output, and for CALL MICROFLOW
steps not even DESCRIBE, which renders the target microflow rather than the
activity's own name.

The dedup helper was written for INSERT and reused by REPLACE.
collectAllActivityNames walks the still-unmodified tree, so the activity
about to be spliced out is still in the name pool when the replacement is
checked against it: a same-name replace always collided with itself.

Free the outgoing activity's name before deduplicating. It is read off the
resolved element, not from activityRef, which may be a caption rather than
the name. Only that one name is freed, so a replacement colliding with a
surviving activity still dedupes — covered by a control test on both
backends.

The MCP backend had the same shape. I introduced it there in #204 on the
guess that the file backends' behaviour was a deliberate conservative
choice, and said so in a comment; it was neither deliberate nor correct, and
that comment is corrected here.

On severity: the report argued the rename detaches in-flight
System.WorkflowUserTask records. Mendix's workflow-versioning documentation
lists "changing names, captions, and titles" as explicitly non-conflicting
and matches activities structurally, so that consequence does not follow.
The case for fixing it is the unbounded accretion and the ADR-0008
idempotence break.

That break is only half closed here: a no-op replace still rewrites the .mpr
because addFreshPersistentID re-mints PersistentId on every activity write in
both engines and neither reads it back. Tracked separately as mendixlabs#949.

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

Every Workflows$* element -- activities, outcomes, boundary events, and the
workflow document itself -- carries a PersistentId. Both engines minted a
fresh GUID for it on every write (addFreshPersistentID in modelsdk, a literal
idToBsonBinary(generateUUID()) in legacy) and neither read the stored value
back, so a rebuilt workflow never equalled the stored one.

The visible consequence is that no-op elision could never fire for a
workflow: re-running an unchanged ALTER WORKFLOW rewrote the .mpr every time
and Studio Pro showed a version-control change on every run. That is the half
of the ADR-0008 break that mendixlabs#944 could not close.

Carry it in canon rather than in the writers. One place covers both engines
and every nested type at once, and the writers keep minting fresh for
genuinely new elements, which is correct: the carry only applies where a
stored counterpart exists.

identityFields/CarryIdentity were the wrong home -- they reach only top-level
properties of the document root, and PersistentId sits on elements
arbitrarily deep in the flow tree. CarryPersistentIDs reuses the structural
pairing TransplantIDs is built on, with the traversal deliberately identical
to pairer's: a divergence would let one element's $ID and PersistentId come
from two different stored elements. Unlike an $ID a PersistentId is not a
pointer target, so substituting one touches only its own element.

TestFreshGUIDFieldsHaveAnIdentityDecision did not catch this and could not:
it sees only properties registered through the codec's FreshGUIDFields, and
both writers minted this one by hand. That blind spot is now written down
where the guard lives.

Verified by byte-comparing the .mpr across repeated no-op runs on both
engines (MXCLI_ENGINE=legacy): three runs each, unchanged every time, where
each run previously produced a different sha. Controls cover a real edit
still writing, a differing $Type not inheriting an identity, injectivity, and
MXCLI_ALWAYS_WRITE still preserving identity while disabling elision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`action: show_page Mod.Detail(Car: $Other)` inside a data view bound to $Car
opened the page with $Car. The argument was not stored, not rejected and not
visible afterwards.

mxcli stores a widget show-page action with an EMPTY ParameterMappings array and
lets Mendix infer the argument from the enclosing widget's context object. That
half is deliberate and twice-confirmed: an explicit Forms$PageParameterMapping
whose Argument is "$currentObject" is rejected as CE0115 "parameters do not
match" (mendixlabs#296, re-confirmed on mxbuild 11.12.1 for findings §56). The half that
was missing is the case where the author names something else — the builder
built PageClientParameterMapping objects and both engines dropped them
(formSettingsToGen takes only the page name; the legacy writer hardcodes the
empty array).

Nothing caught it. `mx check` reports 0 errors on the corrupted page, because an
inferred mapping is a valid mapping, and DESCRIBE prints `(Car: $currentObject)`
— so the description reads as a diagnosis of a lost mapping rather than an
accurate report of a model that never held one. That is the trap §39's reporter
spent three cycles in while distrusting a button that was correct.

Refuse rather than author: mendixlabs#296 already established that Mendix rejects an
explicit mapping, so the only honest options are "bind the context object" or
"say no", and silently doing the first while the author wrote the second is the
bug. Telling them apart needs the context VARIABLE, not its entity — both $Car
and $Other have entity Mod.Car — so the data source's own name for the context
object is now tracked alongside entityContext and an argument is accepted when
it matches either spelling. That keeps the documented form working
(create-page.md's `show_page Mod.EditPage(Product: $Product)` inside a data view
on $Product is the arg-equals-context case) and flags only arguments that are
provably discarded.

Mirrored at check time as MDL-PAGEARG01, so the LSP and `mxcli check` catch it
with no project — the same pairing as MDL-WIDGET09.

Control: stub pageArgumentBindsContextObject to return true and the probe writes
silently again, describing as $currentObject; drop the validator call and the
check-time test reports 0 violations where it wants 1. `mx check` is worthless
as a control here, which is the point of the bug.

Found while verifying findings §39, whose reported half — DESCRIBE omitting the
inferred mapping entirely — was already fixed by 4551a4e.

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

A rule was not an object in the catalog, and nothing ever entered a rule's
body. Both halves matter and they had to land together: an object type without
the body walk would report every document a rule calls as dead, which is worse
than not knowing about rules at all.

Rules now land in microflows_data as MicroflowType 'RULE' — a CATALOG.RULES
view beside CATALOG.NANOFLOWS, and a RULE row in CATALOG.OBJECTS — with their
parameters, activities and complexity, and their bodies indexed into
CATALOG.SOURCE so `search` can find an expression only a rule contains.
builder_references walks each rule the way it walks a nanoflow, so the
documents a rule calls become reachable in the reference graph.

Measured on the reference app with a microflow called only from inside a rule:
before, `show callers` found none and GRAPH_DEAD_ASSETS listed it as dead;
after, it has its caller and is gone from the dead list, while a genuinely
uncalled microflow stays dead. The integration test encodes both — the
unreferenced microflow is the control, so the assertion cannot pass by the
dead-asset view going empty — and was confirmed to fail with the reported
symptom when the rule walk is removed.

Slice 2 of PROPOSAL_rule_documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
Add ALTER PROJECT SECURITY GUEST ACCESS, and fix the gen keys it depends on
fix(test): make a .test.mdl suite report what it actually asserts
fix(workflow): keep the name on a same-name REPLACE ACTIVITY (mendixlabs#944)
fix(domain-model): DROP ATTRIBUTE left orphaned validation rules (CE1613)
claude and others added 27 commits August 21, 2026 09:52
@setup is parsed into TestCase.Setup and read by nothing — it arrived with the
original framework as one row of an annotation table ("Reference to setup
block") and was never designed past that row. `mxcli test` accepts it and does
no setup.

The proposal works from the constraint that decides the design: the test
endpoint runs one microflow per request in a transaction it owns, so anything
that must be undone with the test has to run inside the test's own call, and a
once-per-file fixture cannot be rolled back at all — which under --attach means
seeding the developer's own database.

So it proposes the shape that fits: @setup names a microflow, repeatable, run
before the body inside the test's transaction, declarable once in a file header.
A failing setup is an ERROR naming the microflow, not a FAIL blaming the test —
that attribution and the file-level default are what earn the annotation its
place over the line of MDL an author can already write.

Deleting the annotation instead is kept as the first open question, not buried:
it is a defensible answer and costs nothing to maintain.

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

MDL could not create a rule, so a project's decision logic could be read but
never written, and mendixlabs#939's own reproduction needed a rule hand-built by
rewriting a microflow unit.

Slice 3 of PROPOSAL_rule_documents. createRuleStatement mirrors
createNanoflowStatement verbatim, sharing microflowBody, the flow builder and
the describer; DROP RULE and a "RULE" entry in MoveDocumentTypeByKeyword follow
the same pattern, and MOVE needed nothing rule-specific because a foldered
rule's containment is an ordinary document's.

Authoring is modelsdk-only. The legacy engine refuses with the message menus
already use: a rule document is close enough to a microflow that a half-written
one would look valid, and sdk/mpr has no serializeRule.

What a rule may not contain is refused at check time by the same validateRule
the executor calls, so `check` and `exec` cannot disagree. The restrictions are
measured, not taken from the documentation — converting a microflow unit into a
rule to get past the validator gives, on mxbuild 11.13.0, CE0009 "This action
is not supported in rules." for a create, and CE0103 + CE0139 for a void or
String return.

Two keys the reference documents caught that a build would not. ExportLevel:
Studio Pro writes "Hidden" on every rule and the first authored rule omitted it
while mx check stayed green. Flows: Studio Pro writes it as the bare marker
even when a rule has none, so it is registered as a mandatory list. An
mxcli-authored rule now carries exactly Studio Pro's twelve keys — the test
asserts the set both ways, so neither inventing microflow-only properties nor
dropping one passes.

Verified end to end on the reference app: two rules authored (Boolean with a
String parameter, enumeration with an entity parameter) plus a decision calling
one build to 0 errors; DESCRIBE round-trips; re-running reports "Unchanged";
MOVE, DROP and `show callers` all resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
fix(canon): carry workflow PersistentId across a rebuild (mendixlabs#949)
A workflow calling a microflow that exists nowhere passed
`check --references` with "All references valid" and was written by exec with
exit 0. Only Mendix's own validator noticed, as CE1613. The identical mistake
inside a plain microflow body was caught, because validateFlowBodyReferences
is wired to CreateMicroflowStmt/CreateNanoflowStmt only.

validateWorkflowParameterMappings documents the intended behaviour and defers
a target that is not in the project to "the missing-reference check". That
check was never written, so this restores its assumption.

The gap was not one reference kind. Measured against a baseline binary, every
reference a workflow can hold went unvalidated:

  call microflow target       call workflow target
  user task page              user task targeting microflow
  workflow context entity     the workflow's own module

The module one matters more than it looks: exec creates a module on demand,
so `check` was the only thing between a typo'd module name and a silently
created module -- and it did this for a microflow but not for a workflow.

ALTER WORKFLOW had no case in the validation switch at all and fell through
to "skip validation", so it got nothing, not even a check that the workflow
it targets exists. It now gets the same passes, applied to the activities any
op introduces.

check and exec run different passes -- exec does not run validateProgram --
so the guard is also called from the statement handlers, before
findOrCreateModule. Same shape and reasoning as mendixlabs#833, where exec wrote
microflows check rejected. The scriptContext is nil there: exec applies
statements one at a time, so an earlier statement's output is already in the
project.

Two supporting pieces were missing: scriptContext did not track workflows at
all (collectDefinitions had no CreateWorkflowStmt case), so a script creating
a workflow and then calling it would have reported a false missing
reference; and there was no buildWorkflowQualifiedNames.

False positives are the real risk in a change like this. Verified by diffing
error counts against a baseline binary across every workflow-touching script
in mdl-examples/ with the referenced modules present: identical before and
after, including the 400-line 24-workflow-examples.mdl suite at zero. System.*
targets are exempt via isBuiltinModuleEntity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(pages): refuse a show_page argument that is not the context object
…kills

Slice 4 of PROPOSAL_rule_documents, and the last of the four. A feature nobody
can find is not shipped: `mxcli syntax microflow.rule` now documents the whole
surface, MDL_QUICK_REFERENCE gains a row per statement, and a write-rules skill
sits beside write-microflows and write-nanoflows as the third flow flavour.

Each document carries the same three facts, because each is the one people get
wrong: a rule returns Boolean or an enumeration and nothing else; a decision is
the only place it can be called; and there is no `grant execute on rule`,
because a rule's document stores no module-role security. The restrictions are
listed with the CE numbers measured behind them rather than as prose.

LSP completion needed no change — RULE and RULES are generated from the grammar
keywords.

The proposal is marked done and CLAUDE.md's implemented list records what the
reference documents settled, including the two keys that are invisible to
mx check: ExportLevel, which Studio Pro writes on every rule, and Flows, which
it writes as a bare marker even when empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
TestMxCheck_DoctypeScripts runs every doctype example on both engines, and
rule authoring is modelsdk-only by design — sdk/mpr has no serializeRule, and a
rule document is close enough to a microflow that a half-written one would look
valid, so the legacy backend refuses create/modify/drop rather than emitting
one. Reads work on both engines.

Same shape as the menu skip directly above it, with the same reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
…xlabs#948)

Three defects, each measured on the v1 fixture.

1. The default engine could not READ boundary events.

mdl/backend/modelsdk/workflow_read.go had no boundary-event support at all
(0 mentions) while the legacy parser has had it all along (17). Both engines
WRITE them, so a boundary event mxcli had just written read back as absent:
DESCRIBE rendered nothing and a describe -> edit -> re-exec round trip
silently dropped the timer, its handler flow and the jump inside it. The
reader now reconstructs all three timer variants, wired into the six gen
types that carry them, and renders more than the legacy parser does (legacy
omits the handler's inner call microflow).

2. A rewrite deleted what the script did not restate.

CREATE OR REPLACE|MODIFY rebuilds the workflow from the statement, so a
stored boundary event the script does not mention is gone along with its
handler flow -- measured 1 -> 0 while exec reported "Created workflow" and
exit 0. Nothing signals it afterwards: the result is a valid workflow that
simply no longer does what it did.

checkNoDroppedWorkflowConstructs refuses that, modelled on
checkNoQueuedCalls. Boundary events are authorable, so restating them lets
the rewrite through -- the normal way to edit such a workflow, and what
describe now emits. Event sub-processes are not authorable at all (they exist
in modelsdk/gen but have no semantic type, reader or writer anywhere in
mxcli), so a stored one refuses outright.

The stored side is read from the RAW unit, not through the semantic model:
the reader is what was blind here, and a guard sharing its blind spot cannot
see what it is meant to protect. Matching $Type by substring catches all
three timer variants and any added later.

3. A describer bug the reader fix exposed.

With boundary events finally readable, describe -> exec stopped parsing:
"mismatched input 'outcomes' expecting ';'". The call-microflow describer
emitted boundary events BEFORE outcomes, and the grammar requires the reverse
(workflowCallMicroflowStmt: ... OUTCOMES? BOUNDARY EVENT?). Pre-existing on
the legacy engine, invisible while the default engine emitted neither.

The shipped workflow skill claimed describe -> drop -> exec round-trips
safely, with boundary events conspicuously absent from its list of what comes
back. It now says what does not round-trip and what a rewrite refuses.

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

The workflow roundtrip integration tests named user-task pages they never
created. Reference validation for workflows did not exist, so the missing
page went unnoticed and the workflow was written anyway -- which is the very
thing mendixlabs#943 is about: Mendix rejects that workflow, mxcli did not.

With the check in place, four of them fail:

  page not found: RoundtripTest.ReviewPage (referenced by user task page)
  page not found: RoundtripTest.SubPage   (referenced by user task page)

Create the pages instead of weakening the check. createTaskPages follows the
page shape already proven by roundtrip_mxcheck_datagrid_test.go in this same
suite.

Covers all four names the tests reference, including ApprovePage, which sits
inside a parallel split -- the validator walks nested flows, so it is caught
too even though the run aborted before reaching it.

These tests carry a build tag and skip without an mx binary and the source
project, which is why a green local ./mdl/... did not catch this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat: full rule support in MDL — read, author, catalog (mendixlabs#939)
…ndixlabs#948)

setupTestEnv defaults to the LEGACY backend, and legacy could always read
boundary events. That is precisely why the default (modelsdk) engine having
no boundary-event reader at all stayed invisible: DESCRIBE emitted nothing on
the engine users actually run, so describe -> exec silently dropped the timer
and its handler flow, while these tests stayed green.

Identical to the TableMappings gap already documented in
roundtrip_dbconnection_test.go -- "legacy renders the clause, modelsdk did
not" -- and fixed the same way, by running across gateEngines.

Also pins the USER TASK shape at unit level. The wiring is per-gen-type and
genWf.UserTask (the older type) has no BoundaryEventsItems accessor at all,
so the reader can only reach these through SingleUserTaskActivity /
MultiUserTaskActivity. The integration tests use a user task, so it is worth
asserting the encoder puts one somewhere the reader can actually see it
rather than inferring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ces (mendixlabs#901)

`DELETE_BEHAVIOR PREVENT` parsed, printed "Modified association", and stored
DeleteMeButKeepReferences — overwriting whatever the association had, which for
the reporter was a cascade.

The grammar and the generated parser were never at fault. DeleteBehaviorContext
exposes an accessor for all five tokens; buildDeleteBehavior called CASCADE()
and nothing else, so PREVENT, DELETE_IF_NO_REFERENCES and DELETE_AND_REFERENCES
fell through to the zero value ast.DeleteKeepReferences. That is a *legal*
behaviour, so no layer below could tell it had been substituted.

Two more defects were underneath, found while proving the first fix:

  - Fixing the visitor alone put an out-of-domain enum on disk. ALTER
    ASSOCIATION SET built the stored value as
    DeleteBehaviorType(s.DeleteBehavior.String()), and String() spells the
    prevent case "DeleteIfNoReferences" where Mendix writes
    "DeleteMeIfNoReferences". Both write paths now share one conversion,
    storageDeleteBehavior.

  - DESCRIBE emitted `delete_behavior DELETE_CASCADE`, which is not a token —
    the parser rejects it. It is the line the reporter pasted as their starting
    state, so describe -> edit -> exec died on any cascading association. It now
    emits DELETE_AND_REFERENCES, matching the other two arms.

Two beyond the report: DELETE_AND_REFERENCES (the canonical spelling of
cascade, whose alias CASCADE worked) was downgraded the same way, and the
built-in help advertised the non-token DELETE_CASCADE.

mx check is not the oracle here. Measured on mxbuild 11.6.6, a project carrying
"DeleteIfNoReferences" builds with 0 errors exactly like a correct one — only
Studio Pro refuses it. So the test asserts the stored value is one of Mendix's
three DeletingBehavior values, not merely the one that was asked for.

Verified: each fix reverted individually reproduces its own symptom and no
other; all ten token x path combinations correct end to end on a real 11.6.6
project; on-disk enums legal; mx check 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
@setup was parsed into the test case and read by nothing — no generator, no
runner, no reporter — so `@setup Mod.Seed` did no setup and said nothing about
it. It now names a microflow called before the test's own statements.

The design is in PROPOSAL_test_setup_annotation.md, and turns on one constraint:
the test endpoint runs one microflow per request inside a transaction it owns,
so a fixture that must be undone with the test has to run inside the test's own
call, and a once-per-file fixture could not be rolled back at all — under
--attach it would seed the developer's own database. Emitting the call into the
generated microflow needs no protocol change and behaves the same on --local,
--attach, Docker and the legacy after-startup runner.

  @setup eShop.ACT_SeedCatalog

is repeatable, and a file's header comment may declare one for every test in the
file — the file's fixtures running before a test's own. That is what makes the
annotation worth more than `call microflow X;` at the top of each body, and it
is only readable because a header comment stopped being swallowed by the first
test (mendixlabs#927). A header may carry nothing else: @expect, @verify, @throws and
@cleanup describe one test's execution, and are refused there by name rather
than ignored.

A failing setup is an ERROR naming the microflow, not a FAIL — the test never
ran, and a broken fixture must not read as a broken feature. The endpoint
carries that back as a third verdict prefix; the monolithic runner, which has no
returned verdict, emits an MXTEST:ERROR: line that ParseLogResults now knows.

An unresolvable setup microflow is refused before anything runs, without the
pre-flight check the proposal considered: the generated flows are injected
through `mxcli exec`, whose own check names the missing microflow and writes
nothing. The whole-script --references pre-flight would have refused suites for
references that have nothing to do with setup.

Verified on mxbuild 11.13.0, both generators: the flows were exec'd into a real
project and mx check run against a pristine-copy baseline — 1 pre-existing error
either side, none in the generated flows. Covered shapes include a void fixture,
a value-returning one, two composed, and one on a @throws test. Controls: stub
the emission and the header lookup, and the setup tests fail with the setup
absent from the generated MDL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
fix(workflow): stop CREATE OR REPLACE deleting boundary events (mendixlabs#948)
fix(associations): DELETE_BEHAVIOR PREVENT was stored as keep-references (mendixlabs#901)
feat(test): implement @setup, the annotation that did nothing
The 65 bundled skills that ship with `mxcli init` had no guidance on
standing up an HTTP endpoint you control: zero mentions of Prism,
WireMock or mitmproxy anywhere in the repo. Every REST skill assumed a
live third-party API, so a REST integration was developed against
network, rate limits, credentials and a payload that can change under
you — none of which is where the Mendix defects are.

The new skill separates the two problems that get conflated (something
must answer the request; the app must send the request there), and
covers the mxcli-native endpoint swap that the proxy route is usually
reached for instead:

- `rest call` URLs are expressions, so `@Module.Constant + '/path'`
  plus `run/test --local --constant Name=value` repoints the app with
  no model change and nothing committed; `constant set --apply` flips
  an app that is already running, and `constant list` names the layer
  when the wrong value wins.
- A REST client document's `BaseUrl` is a literal and cannot reference
  a constant, so that case is a one-line `create or modify` instead.
- The forward proxy stays documented for a model you must not edit,
  with the HTTPS/CA caveat that makes it the last resort, plus the
  per-service proxy fields consumed OData carries as data.

Prism's non-obvious behaviour is recorded symptoms-first: root path
mounting, `Prefer: code=404` to drive error handlers, `-d` to catch a
mapping that depends on one fixed payload, spec `security` producing
real 401s, and why a vendor's full contract never starts.

Also corrects rest-client.md, which stated flatly that an omitted
`BaseUrl` falls back to `servers[0].url` — true only when that URL is
absolute. A relative one is warned about and skipped, and the client
then fails at call time rather than at import time.

Registered in the skills README, the generated project CLAUDE.md table,
and cross-linked from rest-client.md and test-app.md (a REST app's
verification prerequisite is a reachable endpoint).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
…bs#948)

Issue mendixlabs#948 asks for a runtime-observability section, on the grounds that the
workflow skill concedes its own failure modes are invisible to every static
gate and then offers no way to look at a running instance. The project skill
it was raised against fills the gap by hand: base64 an admin password pulled
from `ps eww`, register a log subscriber, POST preview_execute_oql with curl.

None of that is necessary. mxcli already ships every piece as a skill --
verify-with-oql, write-oql-queries, analyze-runtime, system-module, test-app,
run-local, runtime-admin-api -- and all seven sync to user projects. What was
missing is any way to find them from where a workflow author stands:
write-workflows.md referenced none of them.

So this is cross-links, not new content, per the documentation rule in
CLAUDE.md -- link to the canonical home rather than restate what lives there.
Some of the hand-rolled recipes are also strictly worse than what ships:
reading `<projectDir>/.mxcli/runtime.log`, which `run --local` tees
automatically, beats registering a log subscriber.

Two runtime traps are stated inline because they are workflow-specific and
have no other home: the declared return type not being what the runtime
checks (caught by MDL004 -- so do not reach for --no-check to get past it),
and a parked instance not being a failed one.

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

docs(skill): point the workflow skill at the runtime skills (mendixlabs#948)
docs(skills): add mock-rest-apis, the missing REST-mocking guidance
@ako
ako merged commit 5f7f526 into mendixlabs:main Aug 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants