Skip to content

fix(workflow): resolve ALTER WORKFLOW activity refs by name before caption - #1106

Closed
ako wants to merge 45 commits into
mendixlabs:mainfrom
ako:claude/beautiful-grothendieck-5a9a8b
Closed

ako wants to merge 45 commits into
mendixlabs:mainfrom
ako:claude/beautiful-grothendieck-5a9a8b

Conversation

@ako

@ako ako commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every ALTER WORKFLOW op that addresses an activity (e.g. insert boundary event on bugSplitJump …) failed with

ambiguous activity "bugSplitJump" (2 matches); use @N to disambiguate

whenever the workflow also contained jump to bugSplitJump. Measured live over the MCP backend against Studio Pro 11.14 (TestApp, workflow.ZzMxcliBug_TimerBoundaryEvents2); bugSplitJump@1 worked.

Cause

buildJumpTo (mdl/executor/cmd_workflows_write.go) names every jump JumpTo and, when the MDL gives no caption, sets Caption to the target's name. Both activity resolvers — mcpWorkflowMutator.searchActivities (MCP) and findActivitiesRecursive / findActivityIndexRecursive (MPR, wfmutator) — matched name == ref || caption == ref into a single pool, so the jump's caption shadowed its target's name. The MPR backend had the same defect; it just had not been reported.

Fix

Both backends now resolve in two tiers: activities whose name equals the reference win; captions are consulted only when no activity carries that name. Ambiguity and @N are judged within the chosen tier, in the unchanged DESCRIBE traversal order.

Behaviour change: a reference that was ambiguous only because of a caption now resolves without @N. X@2, which previously reached the jump, now reports not found — the jump stays addressable by its own name.

The jump's default caption is deliberately left alone: existing projects already carry that shape (so the resolver had to be fixed regardless), and changing the default would rewrite every mxcli-authored jump on the next re-run. DESCRIBE already suppresses the derived caption.

Tests

  • mdl/backend/mcp: TestWFResolve_NameBeatsJumpCaption (fake PED) — boundary event lands on the named activity; caption fallback still works; two caption-only matches remain ambiguous and @2 selects the second.
  • mdl/backend/wfmutator: TestWorkflowMutator_FindActivity_NameBeatsJumpCaption — covers both findActivityByCaption and findActivityIndex.

Control: both tests were written first and fail against the pooled resolver with the reported ambiguous activity message; they pass with the fix. mcp, wfmutator and executor workflow/jump tests pass; gofmt, go vet, make check-findings clean.

Finding recorded in .claude/skills/fix-issue/findings/mdl-backend.jsonl.

Not yet re-verified live against Studio Pro after the fix.

🤖 Generated with Claude Code

claude and others added 30 commits September 14, 2026 11:35
…sed out

Triggered by the irreducible-graph work landing (named join points, then Mode 2
and Mode 3 describe). Sources gained the two new describe-side files, the graph
classifier and the proposal; the existing three kept.

Three changes, grounded in the 13 mdl/executor findings since the last sync.

A FIFTH SHAPE at the end of the survival ordering: "means something else". The
other four all leave evidence — output that will not parse, a missing property, an
invented clause, vanished structure. This one parses, executes, yields a valid
model, drops nothing and invents nothing, and denotes a different program. No
automated signal can see it; only comparing behaviour finds it.

SOME DOCUMENTS CANNOT BE DESCRIBED FAITHFULLY AT ALL, which is a vocabulary limit
rather than a careless describer: MDL's `if` is single-entry/single-exit against
an arbitrary digraph, so the remedy is to extend the language, and per
Boehm-Jacopini one sub-class has no faithful rendering at any effort. The beat
earns its place by telling a reader whether to reach for a fix or a proposal.

VERIFICATION rewritten around the trap that let shape five survive: a round trip
that is a fixed point on the corpus you have is not evidence it is faithful,
because the describer's inference rule was reverse-engineered from that corpus and
agrees with it by construction — the disagreeing case has to be constructed. Plus
three measurement rules that each hid a defect until applied: compare identities
rather than counts; diff the whole corpus against a baseline binary (a refactor
must be byte-identical, a feature must move only its targets); algebra is not
behaviour.

Also removes the quoted "83 of 248" count — the area has since more than doubled
to 615, so the figure was simply wrong. The Do-not-duplicate line now says counts
are computed, per CLAUDE.md's "coverage is computed, never quoted".

Separately, three findings written 2026-09-13/14 used the shard filename
(mdl-executor) as their `area` where the other 613 use the path (mdl/executor),
so digest-status bucketed them into "(32 areas < 5)". Corrected in place before
using those counts to choose this run's scope. This is the THIRD occurrence —
the same correction was made on 2026-09-12 — and the repetition is the tell that
correcting is not fixing: check-findings validates that the field exists, not
that its value is in the vocabulary.

Two SYNC_LOG rows appended, one for the correction and one for the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
The 2026-06-05 adoption plan stops at the cutover and still reads as though
legacy were the default. This covers what it deferred to "Phase 5 — Cleanup"
and never specified.

The plan's whole job is refusing to treat one name as one job. Retiring the
legacy ENGINE (mdl/backend/mpr, 2,808 lines, five production importers) is
small, unblocked and reversible. Retiring the legacy SERIALIZER underneath it
(sdk/mpr, 41,243 lines) is fifteen times larger and gated on consumers that
never touched the engine seam — the public api/ package, the MCP backend, eight
cmd/mxcli commands.

One measurement reorders the sequence. The earlier plan deferred the
mongo-driver v1->v2 migration "to the cutover", reasoning the engines could
coexist on different module paths. They can, but the split does not fall on the
engine flag: modelsdk/ is 117 files v2 and 0 v1; sdk/mpr is 113 v1 and 0 v2; the
adapter between them straddles both. So deleting the legacy backend moves the
driver migration NOT AT ALL — what unblocks v2 is deleting sdk/mpr, which is
behind a product decision about the public API. Sequenced the other way, phase
one looks like it owes a 41k-line migration and never starts.

Everything in the "already true" section was verified rather than inherited: no
"not supported by the modelsdk engine" message remains; errUnimplemented cannot
fire on a default run (17 of 19 methods measured dead, the 2 live ones
implemented, the set pinned by a passing test); the dependency runs one way
(modelsdk does not import the legacy backend — a grep says it does and is
matching a comment); legacy is strictly weaker.

Also records what goes stale on removal, including that the modelsdk package doc
still describes the engine as a read slice whose write methods are unimplemented
and whose CLI prints a read-only warning — three false clauses, the warning
having been gone for some time.

Phases 3 and 4 are deliberately NOT scheduled: they need an answer about whether
anything outside this repository depends on api/, which is a maintainer question
rather than a measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Two of the workflow constructs only Studio Pro could set, which #466 made
rewrites refuse rather than reset (phase 1 of
PROPOSAL_workflow_studio_pro_constructs.md):

  user task … on created microflow Mod.MF
  create workflow … on workflow events (UserTaskStarted, …) microflow Mod.MF as '…'
  create workflow … on any workflow event microflow Mod.MF as '…'

Both engines write and read them in the shape ako/TestApp (11.14.0) stores,
describe emits them, and a rewrite that restates them proceeds; one that
declares fewer than are stored is refused.

Measured on mxbuild 11.13.0 first: on-created takes exactly
(WorkflowUserTask, context) in either order (CE6683) and returns nothing
(CE5012); a handler takes exactly (WorkflowEvent, WorkflowRecord,
WorkflowActivityRecord) in any order (CE6691); and the build does NOT check
event type names — an invented one builds at 0 errors — so mxcli refuses an
unknown name (MDL-WF12) and one measured absent from the project version.
Studio Pro stores the full list for "every event", so `any` expands per
version from a table measured off the 11.6/11.10/11.13/11.14 mxbuilds
(32/36/42/42), and is refused below 11.6.

Fixes found on the way:
- legacy parser read OnCreatedEvent as a string, so every on-created
  microflow read back as none;
- none of the workflow rewrite guards worked on the legacy engine: its raw
  units carry arrays as primitive.A, which the guards' []any switches never
  matched, so a rewrite the modelsdk engine refused deleted stored state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(workflow): author event handlers and on-created microflows
…er problem

Two questions from review, both of which the first draft got wrong by reasoning
from line counts instead of measuring callers.

MCP STAYS, and that is compatible. Its entire sdk/mpr dependency is ONE call —
mpr.Open(path) for a deliberately read-only reader, since writes already go over
MCP to Studio Pro. The naive port looks impossible and the reason is worth
recording: MCP calls 37 methods on that reader and modelsdk/mpr.Reader has 7.
But that is the wrong comparison — modelsdk/mpr is a unit/raw reader and the
semantic decoding lives a layer up. Against the modelsdk BACKEND, 33 of 37 are
already implemented. MCP composes the codec backend for reads; its 15,619 lines
are MCP protocol surface and are untouched.

api/ IS NOT DEPENDENT ON THE LEGACY BACKEND. It imports mdl/backend zero times —
it bypasses the abstraction entirely and holds a concrete *mpr.Writer. The
dependency is five symbols and seventeen methods, and FIFTEEN of the seventeen
are already implemented on the modelsdk backend, with all seventeen already
declared on FullBackend. The two missing (AddAttribute, UpdateAttribute) are
missing precisely BECAUSE api/ is their only caller, and ALTER ENTITY's mutator
is the template for both. The builder surface need not change, so the public API
break the first draft treated as a gate is not required.

The unifying finding, and the reason Phase 3 is now sequenced rather than
deferred: unreachableUnimplemented is a CENSUS OF WHO BYPASSES THE ABSTRACTION,
not dead weight. Its reason column names api/, the MCP backend, and the
cmd/mxcli commands that hold a concrete reader on purpose. Those methods are on
the interface because those callers exist and unreachable because those callers
do not use a backend value. Closing the bypasses makes them reachable or
genuinely deletable — and only after that is sdk/mpr's fate a question at all.

Phase 3 accordingly drops from "L, gated on a product decision" to "M", the a/b/c
options are replaced by a four-step sequence, and MCP is recorded as a fixed
constraint rather than a decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Merging main moved three of the numbers the plan asserts: sdk/mpr 41,243 ->
41,418 lines and 113 -> 114 v1 files, mdl/executor 17 -> 18. The caller
measurements that carry the argument are unchanged — api/ still imports
mdl/backend zero times and needs 17 methods of which 15 are implemented, MCP
still needs 33 of 37, the reachability test still passes, and there are still no
"not supported by the modelsdk engine" messages.

A plan whose figures rot is the specific failure this one was written to correct
in its predecessor, so the verification line is now dated to the merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Phase 2 of PROPOSAL_workflow_studio_pro_constructs.md. An AI agent task
(Workflows$AIAgentTaskActivity, Mendix 11.9+) stores exactly the
call-microflow shape under a different $Type (ako/TestApp, 11.14.0), so it is
the call-microflow statement with `agent` added and, in the semantic model, a
CallMicroflowTask with IsAgent — every walker, validator, catalog edge, naming
rule and ALTER path applies unchanged; only $Type, describe and the rewrite
guard branch on it.

  call agent microflow HR.Classify as aiAgentTask1 comment 'Classify'
    with (Request = '$WorkflowContext')
    outcomes true -> { … } false -> { };

Measured on mxbuild 11.13.0 by writing each shape as a call microflow and
switching only the $Type: everything builds the same (context, System.Workflow
or several parameters; Boolean and enumeration outcomes; boundary events)
except an agent microflow with no parameters, which fails CE1590 — the one
new check. Gated on workflows.ai_agent_task (11.9.0) for CREATE and ALTER.

Describe used to print an agent task as a comment, so a rewrite refused the
whole workflow; it now emits `call agent microflow` and the guard counts
restated agent tasks. The modelsdk engine writes and reads it; the retiring
legacy engine carries the $Type through rather than downgrading the task; the
MCP backend sends it as AIAgentTaskActivity — the shape Studio Pro 11.14's
ped_get_schema declares.

Verified in Studio Pro 11.14 over MCP: the agent task, handler and on-created
shapes are accepted and read back as written; the on-created and handler
signature errors match mxbuild's CE6683/CE6691 text; an agent task without
parameter mappings is flagged; an invented event type is refused at create.

Fixed on the way:
- MCP mapper hard-coded onCreatedEvent NoEvent and sent no onWorkflowEvent,
  silently dropping phase 1 over MCP; and Studio Pro 11.14's workflow
  constructor takes `context` and a string workflowName, so every workflow
  create over MCP failed. The shape is now read off the live constructor
  schema.
- describe printed a call microflow's caption only as a `--` comment, so
  describe -> exec replaced it with the microflow's name; an authored caption
  is now `comment '…'`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
api/ imported mdl/backend ZERO times. It was not "dependent on the legacy
backend" — it bypassed the abstraction entirely, holding a concrete *mpr.Writer
handed to api.New. That is why AddAttribute and UpdateAttribute sat
unimplemented on the codec engine: api/ was their only caller and it did not
call them through a backend value.

api.New now takes a backend.FullBackend, with an api.Open convenience for the
common case. The builder surface is untouched, so this is not a breaking change
to anything but the constructor — and nothing in the repository imports the
package, so the blast radius in-tree is its own tests. The gain is not tidiness:
the same fluent builders now run against any backend, including a live Studio
Pro over MCP, which was unreachable while the package held a concrete writer.
Open owns the connection it makes; a backend passed to New belongs to the caller
and Close leaves it alone.

AddAttribute and UpdateAttribute are implemented on the codec backend and struck
off unreachableUnimplemented, which failed loudly when they became reachable —
exactly what that pin is for. Two properties are worth the tests they get: the
attribute keeps its stored $ID (the runtime keys on identity), and the list keeps
its order. The second is not free — the generated list offers only Append and
Remove, so an in-place replace means rebuilding it, and the naive
remove-then-add moves the edited attribute to the bottom of the entity. Control:
breaking the rebuild fails the order test.

THE SUITE THAT SHOULD HAVE VERIFIED ALL THIS HAD ONLY EVER SKIPPED. api/'s ten
integration tests pointed at ../mx-test-projects/test-source-app, which is not in
the repository, so every one of them skipped on every machine and in CI since
they were written — the mendixlabs#808 shape, and it meant `go test ./api/` passing said
nothing about a refactor that rewrote every call site in the package. They now
run against the committed testdata/expr-checker fixture the codec backend's own
tests use, and a missing fixture is FATAL rather than a skip, because a committed
fixture's absence is a broken checkout rather than an optional dependency. Ten
tests, zero skips, all passing against the codec engine.

First step of Phase 3 of docs/plans/2026-09-14-retire-legacy-engine.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
MCP opens the project read-only and sends writes to Studio Pro, so it needs
a semantic reader — and it held a concrete *mpr.Reader for one, because the
codec backend did not offer three of the reads it takes: GetDomainModelByID,
GetWorkflow and ListNavigationDocuments. That is the entire reason those three
sat on FullBackend with no caller through a backend value; the reachability
pin names MCP for each.

Implements them in mdl/backend/modelsdk/mcp_bypass_reads.go. Each is a
re-keying or a widening of a read this package already does, so all three share
their conversion (domainModelFromGen, workflowFromGen, navProfileFromGen) with
their siblings — workflowFromGen is extracted from ListWorkflows for this, so a
single-item read cannot drift from the same item in a listing.

Adds Backend.ConnectReadOnly: Connect opens ReadOnly:false and would lock the
file Studio Pro owns, which is the one thing MCP must not do.

Connect had no test — 190 in the package, not one called it — so the swap would
have landed unverified with the suite green. connect_reads_test.go covers it,
with two controls: reverting ConnectReadOnly makes the write-refusal test fail
with the reported symptom, and a companion test shows the same write succeeds
read-write, so the refusal is not incidental. The workflow read seeds a workflow
first; the fixture has none, so looping over the listing would have passed
without calling GetWorkflow at all.

Step 2 of Phase 3 in docs/plans/2026-09-14-retire-legacy-engine.md. Five methods
struck off unreachableUnimplemented across this and the api/ port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
A layout whose placeholder is not named `Main` passed `mxcli check -p
--references` AND `mxcli exec` ("Created layout"), then failed the build
with CE0848 — with no DROP LAYOUT to undo it (mendixlabs#1063).

The rule, measured on Mendix 11.12.1 against a layout NO PAGE USES, so
none of it depends on a page binding to the layout:

  placeholder Main               -> 0 errors
  placeholder Main + Content     -> 0 errors  (extra names are fine)
  placeholder Content            -> CE0848 "No placeholder with the name
                                    'Main' found. There should be exactly one."
  placeholder Main + Main        -> CE0849 + CE0495
  placeholder Main + Side + Side -> CE0495 "Duplicate name 'Side'."

So there are two independent halves, and CE0495 shows the second is not
about Main at all: placeholder names must be unique, whatever they are.

The root cause was a documented belief rather than an oversight. CLAUDE.md
said "Which placeholder is 'main' is a naming convention (22 of 22 name one
`Main`; a page binds by qualified name anyway)", the layouts skill said "the
convention is the mechanism", and the write-time guard implemented exactly
that: hasAnyPlaceholder accepted any placeholder under any name. The check
that existed matched the docs, which is why looking for a missing guard would
never have found this — the rationale was what needed verifying.

The rule now lives once, in types.CheckLayoutPlaceholderNames over bare
names, and is applied by both `mxcli check` (MDL081 for the Main rule,
MDL082 for duplicates, in the project-free pass so CI reaches it) and the
writer. Two copies in two currencies is how a resolver drifts.

An error rather than a warning: unlike MDL078 nothing here is a snapshot of
a Mendix asset that a later version might extend, so a flagged layout cannot
build today.

Verified end to end on mxbuild 11.12.1: the reporter's script is now refused
at check time (exit 1) and writes nothing, and `Main` + `Side` builds at 0
errors. Control: stubbing the shared rule to always-OK makes both the
executor and writer tests fail with the reported symptom.

Refs: mendixlabs#1063

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
`placeholder Main { }` inside a CREATE LAYOUT passed `mxcli check`, then
failed at exec with `layout "X" declares no placeholder` — a message that
flatly contradicts a script which plainly says `placeholder Main`. The
failed exec had already created the module (mendixlabs#1063).

One grammar rule serves two opposite jobs, told apart by shape rather than
by context: `if c.LBRACE() == nil` makes a bodiless placeholder a
DECLARATION widget, while the braced form is routed to
buildPagePlaceholdersV3 — the page-side job of FILLING a layout's slot. In
a layout there is no such job, so the braced form was dropped on the floor
and the layout ended up with zero placeholders.

When one parse rule is disambiguated by shape, the wrong shape has no error
path by construction: it silently means the other thing. Reaching for the
braced form here is also the natural mistake, since every other layout
element takes a body and `alter page` uses it for real. DESCRIBE emits the
bodiless form, so round-tripping never produces this and no test covered it.

The Builder now carries `inLayout`, saved and restored around the layout
body build because the same body builder serves pages, and collects the
dropped names into CreateLayoutStmt.BracedPlaceholders for MDL083 to report
at check time. The braced form is still dropped — recording it is a
diagnostic, not a decision to start honouring it.

Verified: the reported script is refused at check time and no longer creates
the module on the way to failing. Controls: a page's braced placeholder must
still fill its slot, and a layout's bodiless form must still produce a real
widget.

Refs: mendixlabs#1063

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
Layouts were the only document mxcli could create and alter but not delete
(mendixlabs#1063). Every other doctype has a DROP — pages, snippets,
microflows, entities, queues, rules, folders — and the gap bit hardest on a
layout mxcli itself had just written badly: the reported script created a
layout that fails the build, and there was no headless way to remove it.

This is wiring, not new capability. DeleteLayout already existed and worked
on both engines, and is exercised on every CREATE OR REPLACE LAYOUT, whose
replace half is a delete followed by a create. Only the grammar alternative,
AST node, visitor branch and executor were missing.

Pages still bound to the layout are named in a warning and the drop
proceeds, rather than being refused. Three reasons, in order of weight:
no other DROP in mxcli refuses on dependents; drop-then-recreate under the
same name is how a layout is corrected, with the pages rebinding by
qualified name (verified end to end — back to 0 errors); and the order of
statements in a script is the author's business.

The warning is load-bearing because of what the pages then do. Measured on
11.12.1, dropping a layout one page was bound to:

  [error] [CE1613] "The selected layout 'MyFirstModule.App_Mine' no longer
                    exists." at Page 'MyFirstModule.Home_Web'
  [error] [CE1613] "The selected placeholder 'MyFirstModule.App_Mine.Main'
                    no longer exists." at Content for placeholder 'Main'

Both name the page end and never the layout that was removed, so an
unwarned drop surfaces a build later attributed to a document the author did
not touch. The warning names the pages and gives the `alter pages … where
layout =` repoint.

Control: a page bound to a different layout must not appear in the warning,
an unused layout must produce none at all, and removing the warning makes
the test fail with a bare "Dropped layout" line.

Refs: mendixlabs#1063

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
CLAUDE.md said "Which placeholder is 'main' is a naming convention (22 of 22
name one `Main`; a page binds by qualified name anyway)" and the layouts
skill said "the convention is the mechanism". mxbuild validates it as a rule,
measured on 11.12.1 against a layout no page uses: CE0848 with no Main,
CE0849 with two, CE0495 on any repeated name.

That sentence is why the write-time guard accepted any placeholder under any
name, so correcting it is part of the fix rather than housekeeping — the code
matched the docs exactly, and the docs were wrong.

Also documents DROP LAYOUT and the declared-vs-filled placeholder
distinction (MDL083) across the same set: CHANGELOG, the layouts skill,
MDL_QUICK_REFERENCE, `mxcli syntax` (new layout.drop topic), a bug-test
example, and three findings records.

Refs: mendixlabs#1063

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
Phases 1 and 2 of docs/plans/2026-09-14-retire-legacy-engine.md. The codec
engine has been the default since the 2026-06 cutover and nothing routes to
legacy any more; it was five files away from having no non-test importers.

Deleted: mdl/backend/mpr (the backend), mdl/enginecompare and the engine-diff
make target (a package that compares two engines), and the `bson compare`
command. Repointed mdl/repl, two examples and the test helpers at the codec
backend, and dropped the nightly matrix's engines leg.

--engine and MXCLI_ENGINE survive as a warning-only NO-OP rather than being
removed. Deleting the flag would fail a script pinning `legacy` at argument
parsing with "unknown flag", which says nothing about what changed; it now
warns once and runs. Verified both spellings warn and still execute.

Three things the plan did not anticipate, each invisible while two engines
existed and none caught by the compiler:

- errUnimplemented still told users to "rerun with MXCLI_ENGINE=legacy" — a
  runtime message naming a fallback that no longer exists is worse than none,
  because the user follows it into a second failure. It now asks for a report,
  which is what reaching it means.
- setupTestEnv defaulted to the LEGACY backend, so most of mdl/executor's
  integration tests were exercising the retired engine rather than the one
  users get. They run on the codec engine now — coverage that was always
  intended and had silently not been happening.
- TestODataService_EngineWriteParity compared two writers' key sets. The
  comparison is vacuous with one engine, so it is dropped and the property it
  was a means to is kept: a published OData service retains its role grants,
  which mx check reports as 0 errors either way. Renamed to match.

Kept deliberately: the doctype gate's engine matrix, at one entry. Its
selection still turns a stale MXCLI_TEST_ENGINES=legacy into a loud failure
rather than a gate that selects zero engines and reports success.

One test deleted: TestCustomHandlerLegacyRefuses, which asserted legacy's
refusal of a construct it could not serialize. The codec engine's positive
coverage of that construct is intact.

Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (543
pass), check-findings. Executor integration suite green at 562s, down from
642s now that the doctype gate runs once instead of twice; mdl/backend, api,
marketplace and repl integration suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Retire the legacy engine: delete the backend, port api/ and MCP off it
…ec backend) into feat/workflow-ai-agent-task

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3 step 4 of docs/plans/2026-09-14-retire-legacy-engine.md: re-run the
reachability probe over what was left. Six of the eleven remaining entries were
not bypasses awaiting a port — nothing anywhere wanted them.

scripts/backend-reachability.sh reports DEAD for "nothing calls this through a
backend value", and that one verdict covers three situations wanting opposite
fixes: a BYPASS (a caller wants it but holds a concrete reader/writer — port the
caller), an ORPHAN (no caller anywhere — delete), and a DUPLICATE (callers
exist, but through a narrower package-local interface with a different
signature — also delete). The probe cannot separate them; a grep for callers
under any type can.

Deleted, all measured DEAD: the whole WidgetSerializationBackend interface
(SerializeWidget, SerializeClientAction, SerializeDataSource,
SerializeWorkflowActivity), GetUnitTypes and UpdateLayout.

- SerializeWidget/DataSource are superseded by WidgetBuilderBackend's *ToOpaque
  pair, whose own comment says "This replaces the direct mpr.SerializeWidget
  call" — the supersession was known and the old pair never removed.
- SerializeClientAction and SerializeWorkflowActivity are reached through
  pagemutator's and wfmutator's own deps interfaces, which declare them
  returning bson.D rather than (any, error). The vestigial *Backend copy of
  SerializeWorkflowActivity carried a comment claiming ALTER WORKFLOW used it;
  ALTER WORKFLOW uses codecWorkflowDeps.
- GetUnitTypes exists only on modelsdk/mpr.Reader; UpdateLayout was superseded
  by the page mutator when ALTER LAYOUT landed.

Both generated stub files regenerated (276 -> 270 methods), mock stubs dropped.
Census 11 -> 6, and all six that remain are genuine bypasses.

This corrects a claim shipped in the previous PR: the census header and the plan
both said the list "shrinks by closing a bypass, never by deleting methods",
which holds for a bypass and is wrong for the other two. Both are rewritten to
name the three causes, as is the probe script's own header, which still told the
reader to fall back to the deleted engine.

Gates: build, vet (incl. -tags integration), go test ./..., check-mdl,
check-findings. The census pin (TestNoReachableUnimplementedBackendMethods)
passes at six entries with its control intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
feat(workflow): AI agent tasks with `call agent microflow`, verified in Studio Pro over MCP
Conflict in CHANGELOG only, and the usual shape: both sides appended under
`## [Unreleased]`'s `### Added` and `### Fixed`. Both sides kept — verified
zero deletions in the CHANGELOG diff against origin/main.

Main's #468 deleted the legacy sdk/mpr BACKEND (mdl/backend/mpr) and the flag
selecting it. Nothing in this branch touched it: the writer guard here is on
the modelsdk path, and sdk/mpr the package (readers/writers) is untouched, so
the merge is content-only. Re-verified on the merged tree — build, full test
suite, lint, corpus sweep (0 failures), findings — and the three fixes still
fire: MDL081 on the reported script, MDL083 on the braced form, DROP LAYOUT
parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
Five TestConnect_* tests from #468 built the backend with an empty dial
address, so defaultDial rewrote 127.0.0.1 to host.docker.internal wherever
that name resolves. In a devcontainer the request went to the Docker host
instead of the httptest listener and Connect failed with "connection
refused"; on CI the name does not resolve, so the suite was green.

Route the three constructor calls through backendFor, which passes the
listener address explicitly the way fakePED.connectClient does.
defaultDial is unchanged — it is correct for a real Studio Pro.

Control: with the dial reverted to "" the same five tests fail in the
devcontainer with the reported message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…de by, await all users

Phase 3 of PROPOSAL_workflow_studio_pro_constructs.md.

  multi user task Vote 'Vote on the request'
    page HR.VotePage
    participants 80 percent
    decide by threshold 60 percent fallback 'Reject'
    await all users
    outcomes 'Approve' { } 'Reject' { };

Rules: consensus / majority more than half / majority most chosen /
threshold <n> percent|votes, each with fallback '<outcome>'; veto
'<outcome>'; microflow M. Omitted clauses are what a rebuild has always
written, so existing describe output is unchanged.

The shapes come from Studio Pro, not gen: the examples were built in Studio
Pro 11.14 over MCP, saved to ako/TestApp, and the saved document is the
reader's fixture — "more than half" is an Absolute majority, a percentage
threshold Relative, and both outcome pointers hold the outcome's $ID.

Measured on mxbuild 11.13.0: fallback required for consensus/majority/
threshold (CE1866), veto outcome required (CE1867) — MDL-WF13 at check time,
also for a name that is none of the task's outcomes; a decision microflow
returns String (CE5012), parameters free; thresholds and participant counts
are not range-checked by the build, so not by mxcli.

Fixed on the way:
- a workflow rewrite silently reset every multi-user task's participant
  count and "await all users" (never guarded); a rewrite not restating them,
  or a stored completion rule, is now refused.
- MCP: multi-user tasks could not be created on Studio Pro 11.14 (the
  constructor takes a taskPage element); PED's default consensus has no
  fallback (CE1866) and its constructor drops a more-than-half majority's
  fallback, which is now set by the outcome's $ID afterwards. Verified live:
  created, read back, no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After the orphans and duplicates were deleted, the six remaining census entries
were all the same kind of caller: cmd/mxcli's bson dump/discover, diag,
extract-templates and examples/read_project — raw-unit debugging and export
commands holding a concrete sdk/mpr reader because raw access is the thing they
exist to provide.

Porting them was considered and declined. The backend interface speaks the
semantic model (ADR-0005); routing a BSON dumper through it would either widen
that interface with raw accessors, undoing the decision, or make the tools worse
at their only job. The framing this plan shipped ("the list shrinks by closing a
bypass") did not offer that option — a second way it was too narrow, alongside
the orphan/duplicate correction: some bypasses are correct.

unreachableUnimplemented becomes a standing record rather than a to-do list, and
its header now says so. It keeps its value as a tripwire: a NEW entry still
means either a new bypass appeared or a method was added that nothing calls, and
the two want opposite fixes, so the cause has to be established rather than a
row added to silence the failure.

sdk/mpr does not go away with this — those six commands still import it, as does
the workflow serializer #469 extended. What Phase 3 delivered is that no engine
path reaches it: the executor, the backends and api/ are clean. Removing the
serializer is downstream of Phase 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
The plan's Phase 4 opens "with [sdk/mpr] gone" — and Phase 3 closed by keeping
it, so its premise did not survive. Measuring it properly shows two migrations
that do not gate each other, and the one worth doing first is not the driver
migration.

Also corrects §7.4, which said "those six commands still import sdk/mpr". That
was wrong by a factor of four and pointed at the wrong files: the census tracks
FullBackend METHODS with no caller through a backend value, not importers. 28
non-test files import sdk/mpr, including 7 in cmd/mxcli/docker, two mdl/executor
validators, and modelsdk.go — whose Reader and Writer are type ALIASES to
sdk/mpr's, so the published library's root API is the legacy serializer.

4a (port the 28 callers) is the half that pays: 23 of 28 use only mpr.Open, so
the dependency is one read-only constructor rather than the serializer's bulk,
and the port is the MCP move repeated — modelsdkbackend.New() + ConnectReadOnly.
The 140-vs-47 reader method comparison that makes it look impossible is the same
wrong comparison the MCP port already disproved: the semantic methods live on
the codec backend, a layer up from modelsdk/mpr's raw reader. Its real gate is
modelsdk.go's public API, which should be decided before 4a starts rather than
during.

4b (convert mdl/'s mutator layer to v2) is independent: those 19 non-test v1
files are v1 because pagemutator/wfmutator/widgetobj declare their deps
interfaces in v1 bson.D, not because they bridge to sdk/mpr, so they do not
shrink when it goes — which is what §4 assumed.

Measured: 249 v1 files, 174 v2, and only 6 importing both — all in
mdl/backend/modelsdk, where the crossing is safe because the interchange is
bytes and BSON bytes carry no driver version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
fix(layout): enforce mxbuild's placeholder rule, and add DROP LAYOUT
modelsdk.go — the published library's root API — had Reader and Writer as type
ALIASES to sdk/mpr, so the documented entry point was the legacy serializer.
That was the single biggest gate on deleting it.

Open and OpenForWriting now return a backend.FullBackend from the codec engine.
Repointed all 14 root-package importers: cmd/mxcli/marketplace (7 files),
cmd/mxcli (4), and both examples. Close becomes Disconnect; the two places
typing *modelsdk.Reader/Writer take backend.FullBackend.

Two methods were needed to do it without a bypass:

- ExportJSON on the codec backend. Pure fan-out over the backend's own List*
  methods, so it cannot drift from what every other reader reports. Its only
  caller was examples/read_project holding a concrete sdk/mpr reader, which is
  why it sat on the census — the probe now says LIVE, so it is struck off by
  closing the bypass rather than by deleting the method. Census 6 -> 5.
- AddRawUnit on RawUnitBackend. The marketplace transplant copies a module's
  units between projects verbatim, and copying verbatim is the point: decoding
  and re-encoding would mint fresh identities, and an entity's GUID is what the
  runtime keys the database on.

A behaviour difference found by RUNNING the example, not by any test:
CreateEntity and CreateAssociation left the caller's element holding an EMPTY
id, so the next call failed with "entity not found: " — note the blank after
the colon. The legacy writer populated these. assignID only fills an empty id,
so api/ was unaffected (its builders pre-assign one), which is exactly why the
green suite never saw it. copyAssignedIDs writes the minted ids back, matching
attributes BY NAME rather than by index: entityToGen can add, skip or reorder
(an audit pseudo-type becomes a generalization, not an attribute), so an
index-based copy would hand the caller another attribute's identity — which
looks like it worked and is worse than the empty id.

Both examples run end to end against a fixture and their writes verified in the
model: 6, 3 and 2 attributes land where every one of them used to error.

Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (543),
check-findings. The ID fix has a revert control that fails with the reported
symptom, and a companion test that a caller-assigned id survives — without it
the test would pass against an implementation that overwrites every id and
breaks api/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
claude and others added 15 commits September 15, 2026 11:02
Phase 4a of PROPOSAL_workflow_studio_pro_constructs.md. Three workflow
constructs only Studio Pro could author become MDL:

  event subprocess <name> ['<caption>'] on [non] interrupting notification|timer … { … };
  notification <name> [comment '<caption>'];
  boundary event [non] interrupting notification <name> ['<caption>'] { … }

Describe used to leave sub-processes out, print a notification activity as a
comment and drop notification boundary events. The shapes come from ako/TestApp
(Studio Pro 11.14, built over MCP and saved); that document is the reader's
fixture, since gen has no type for the timer starts, the notification activity
or the notification boundary events, which are read off the raw document.

Measured on mxbuild 11.13.0: a sub-process body with no end is CE0105 (the
builder appends the End), a jump across flows CE6682 (MDL-WF05 now scopes jumps
per flow), a timer start with no expression CE0126 (MDL-WF14), two interrupting
boundary events on one activity CE6697 (MDL-WF15), a start named like an
activity CE0495 (names deduplicated across flows). Version gates were measured
by loading each construct into blank 11.10-11.13 projects: notification starts
11.8 (gen; load from 11.10), notification activity and boundary events 11.11,
timer starts 11.13.

The rewrite guard now requires sub-processes and notification activities to be
restated, walks sub-process bodies, and does not count a sub-process's implicit
End. Fixes a silent loss: a rewrite from describe output deleted every
notification activity (measured 1 -> 0 with the previous build).

MCP: sub-processes go into the constructor and a rewrite replaces the stored
ones. Verified live against Studio Pro 11.14, which also showed that the single
user task constructor drops boundary events (notification events are now
re-added after create and update) and that its interrupting constructors
rewrite a boundary path's ending (such notification paths are refused with the
ending Studio Pro needs). Timer boundary events are affected by the same
constructors and are left for a separate change.

ALTER WORKFLOW's insert boundary event refuses notification kinds, which need a
name the op does not carry; the three boundary-event writers now share one
EventType -> $Type map instead of defaulting unknown kinds to an interrupting
timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Close Phase 3, and cut the root package off sdk/mpr (Phase 4a gate)
test(mcp): dial the fake PED at its listener, not through defaultDial
feat(workflow): multi-user task completion rules — participants, decide by, await all users
Five files, and the slice splits into two ports rather than one: the Phase 3
decision that raw tools keep raw access does NOT mean they keep sdk/mpr.

Raw tools move to modelsdk/mpr, the v2 RAW reader. bson dump, bson discover and
diag moved by import swap alone — ListRawUnits, GetRawUnitByName,
GetRawMicroflowByName, ListAllUnitIDs and ContentsDir have identical signatures
on both readers, and RawUnitInfo is field-for-field identical. So they honour
Phase 3 and land on v2 at the same time.

project_tree.go and debug_resolve.go go through the backend instead. The first
calls 36 semantic reads, every one already on FullBackend; the second needs
ParseMicroflowBSON, which is a backend method.

That also corrects the Phase 3 write-up, which called every remaining bypass a
raw-unit debugging tool. project_tree.go is not one, and the census could not
reveal the mistake: it lists only methods with NO implementation, so a caller
holding a concrete reader while calling only implemented methods never appears.
The complete list of bypasses is the sdk/mpr importer list, not the census.

A regression the port introduced, caught only by a baseline diff: project-tree
silently lost System.VerifyPassword — 131 bytes out of 78KB of JSON, build
clean and every test green. The System module's Java actions are platform
built-ins with no stored unit; sdk/mpr synthesized them and the codec backend
did not. The definitions move to modelsdk/meta beside the virtual System
module's entities, both codec listings append them, and sdk/mpr delegates rather
than keeping a second copy of a hand-maintained platform list.

The technique is the transferable part: build a binary from the pre-port commit
and require byte-identical output from every command whose reader changed. No
test caught this and none would have. A synthesized element is what a reader
swap loses, because it lives in one reader's code rather than in the data.

Regression tests cover both listings, with a control that the STORED actions are
still returned — without it they would pass against a backend that returns only
the synthesized ones. Reverting the fix fails with the reported symptom.

Importers 27 -> 22. Gates: build, vet (incl. -tags integration), go test ./...,
check-mdl (544), check-findings. project-tree, bson dump and bson discover are
byte-identical to the pre-port binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
feat(workflow): event sub-processes and notification events
… could not build

Phase 4b of PROPOSAL_workflow_studio_pro_constructs.md.

  $Notified = notify workflow $Workflow target Module.Workflow.ElementName;

The target names the element a notify resumes: a notification-started event
sub-process's start, a notification activity, a notification boundary event
or a wait for notification. Mendix stores a different NotifyTarget $Type for
each (ako/TestApp ZzMxcliExample_Notify, Studio Pro 11.14, now the reader's
fixture), so the executor looks the element up in the stored workflow and
writes the matching type; an element no notification reaches (a timer start,
a user task, a timer boundary event) is refused with the names that would
work. Before Mendix 11.7 the action names its wait for notification directly
(Activity), and that form is written there.

Measured on the 11.6, 11.10 and 11.13 mxbuilds, a notify without a target is
CE0166 — so every notify mxcli wrote failed the build, and describe dropped a
stored target so a rewrite removed it. A notify with no target is now
MDL-WF16, refused by check and exec.

Fixes the output variable being lost on read: modelsdk/gen bound it to the key
VariableName where Mendix stores OutputVariableName (11.6 metamodel and Studio
Pro 11.14 agree; under VariableName the variable is undefined, CE0109). Fixed
as a storage-name override on NotifyWorkflowAction only — other gen types bind
VariableName correctly.

MCP: the notify action is mapped (it was unsupported); the notifyTarget shape
matches what Studio Pro 11.14 returns for TestApp's saved actions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(workflow): notify workflow … target, and fix notify actions that could not build
Port cmd/mxcli's readers off sdk/mpr (Phase 4a, first slice)
…ption

Every ALTER WORKFLOW op failed with `ambiguous activity "X" (2 matches)`
whenever the workflow contained `jump to X`. buildJumpTo defaults a jump's
caption to its target's name, and both activity resolvers (MCP
mcpWorkflowMutator.searchActivities, MPR wfmutator) matched name OR caption
into one pool, so the jump's caption shadowed the target. Measured live over
MCP against Studio Pro 11.14.

Resolve in two tiers in both backends: name matches win; captions are
consulted only when no activity carries that name. Ambiguity and @n are
judged within the chosen tier, in the same DESCRIBE traversal order.

The jump's default caption is left unchanged: existing projects already
carry that shape, and changing it would rewrite every mxcli-authored jump on
the next re-run.

Tests in both backends fail with the reported message against the pooled
resolver (control) and pass with the fix.

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

ako commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Opened against the wrong repository by mistake (intended for the ako/mxcli fork). Closing — sorry for the noise.

@ako ako closed this Sep 15, 2026
ako added a commit to ako/mxcli that referenced this pull request Sep 16, 2026
…ht files

Three defects, one visible and two that the visible one uncovered.

THE FAILURE. `gh pr diff` reads an endpoint that caps at 20,000 lines and
answers HTTP 406 above it. mendixlabs#1107 is 21,366 changed lines, so the step died
with "PullRequest.diff too_large" under `bash -e` and took the job with it
-- the only hard-fail path in a workflow whose every other error (missing
key, non-200, empty content) is a warning and exit 0. mendixlabs#1106 failed the same
way hours earlier. The per-file endpoint has no such cap: measured on mendixlabs#1107
it returns all 278 files, 3 without a patch (the .bson fixtures). The diff
is now rebuilt from those patches, and a fetch problem downgrades the
review rather than failing the PR.

WHAT THE REVIEW ACTUALLY SAW. `head -c 80000` truncates by BYTES over an
alphabetically ordered diff. Measured on mendixlabs#1107, whose full diff is 1.29 MB:
the window covered 3 of 275 files, and all three were
.claude/skills/fix-issue/findings/*.jsonl -- append-only evidence logs.
Zero Go code. mendixlabs#1095 was cut to 23% and mendixlabs#1090 to 19% the same way, so this
is what every large review has been looking at.

Selection is now file-aware: generated, fixture and append-only paths are
skipped, whole file patches are taken most-changed-first, and no single
file may exceed a tenth of the budget. That last clause is load-bearing --
ordering by size with no cap put three files in the budget and nothing
else, while ordering alphabetically missed mdl/backend/mpr/backend.go, the
1,163-line change this PR is named after. With both, mendixlabs#1107 yields 11 source
files including that one. A manifest of all 278 changed files goes in the
prompt regardless, and the note tells the model to say when a finding needs
a file it cannot see rather than assume absence. A normal PR is unaffected:
mendixlabs#1098 comes through as 13 of 14 files, nothing over budget.

Not fixed here, because it is not in this repo: every "successful" run since
at least 2026-09-04 got HTTP 401 from OpenRouter and posted nothing, so the
green checks were not reviews. The key has been rotated; this PR is the
first run that can reach the model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNDe35kDNsMX5cz4Ahn4rk
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