Skip to content

Sync ako/mxcli: retire the legacy engine (sdk/mpr importers 27 → 0), workflow authoring, layouts, MCP fixes - #1107

Open
ako wants to merge 76 commits into
mendixlabs:mainfrom
ako:main
Open

ako wants to merge 76 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Syncs ako/mxcli:main into upstream. 39 non-merge commits since #1099,
in five themes.

Retiring the legacy engine — complete

sdk/mpr went from 27 importers to 0. The codec (modelsdk) engine is now
the only local engine; --engine / MXCLI_ENGINE survive as a warning-only
no-op.

  • Plan, and three re-measurements that each corrected it — Phase 3 turned out to
    be a bypass problem rather than a serializer problem, and Phase 4 two
    independent migrations rather than one
  • api/ routed through the backend abstraction; the MCP backend's local reads
    pointed at the codec
  • The legacy backend and its selecting flag deleted, plus the dead FullBackend
    surface the census had mislabelled
  • Phase 4a in five slices: the root package, cmd/mxcli's readers,
    cmd/mxcli/docker, mdl/executor, and the rest of cmd/mxcli
  • FindCustomWidgetType implemented on the codec backend — the last method
    forcing a caller to hold a concrete reader
  • examples/ and scripts/mprsnapshot finish it, with a guard test that fails
    naming any file which reintroduces the import

Two findings from that arc are worth the reviewer's attention, because both were
invisible to the compiler and to a green test suite:

  • The unimplemented-method census cannot see a caller holding a concrete
    reader.
    It lists methods with no implementation; a caller reaching an
    implemented method through *sdk/mpr.Reader never appears. That hid
    project_tree.go's 36 semantic reads and cmd_extract_templates.go's
    FindCustomWidgetType. The complete list of bypasses is the importer list,
    not the census.
  • An any field crossing an engine boundary can carry the right type name and
    the wrong package.
    modelsdk/mpr builds widget BSON with the v2 driver and
    every caller asserts v1, so a mismatch reads widget type is bson.D, want bson.D and a cast written to silence it panics. Converted at the boundary.

Workflow authoring

  • Event handlers and on-created microflows; event sub-processes and notification
    events
  • notify workflow … target, which also fixed notify actions that could not
    build
  • AI agent tasks via call agent microflow
  • Multi-user task completion rules — participants, decide by, await all users
  • ALTER WORKFLOW resolves activity refs by name before caption; @N counts
    every name-or-caption match; @position survives a rewrite on workflow and
    mapping actions

Layouts

  • DROP LAYOUT — layouts were the only doctype mxcli could create and alter but
    not delete, which bit hardest on a layout mxcli itself had just written badly
  • The placeholder rule mxbuild actually applies: exactly one placeholder must
    be named Main (CE0848 / CE0849) and names must be unique (CE0495). This file
    had documented it as a naming convention, and the write-time guard implemented
    that belief — so a bad layout passed check and exec and failed a build
    later
  • A braced placeholder is reported (MDL083) instead of dropped silently

MCP / Studio Pro 11.14

  • Microflows created against Studio Pro 11.14's skeleton constructor
  • Timer boundary events stored correctly
  • Workflow list order kept in create-or-modify and ALTER: one
    ped_update_document batch is not applied in the order sent, which stored a
    flow's activities reversed while every update reported SUCCESS

Fixes, tests and docs

  • check --references resolves constraint associations through the
    generalization chain
  • A describe-roundtrip assertion updated for TARGET becoming a lexer token:
    adding a keyword silently requotes DESCRIBE output for every element whose
    name matches it
  • Wiki re-synced; the 2026-09-15 findings digested into three bug-pattern pages

Verification

Every commit went in green. On the final state: make build, make vet
(including -tags integration), go test ./..., make check-mdl (547),
make check-findings (1,086 records), and the repo-wide
make test-integration — exit 0, no failures

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 30 commits September 15, 2026 14:14
Two check-time validators imported sdk/mpr directly, which CLAUDE.md's
backend-abstraction checklist forbids for the executor. Importers 15 -> 13.

They open their own short-lived read-only connection rather than using
ctx.Backend, because ValidateProgram takes a project PATH, not a backend —
`check --references` validates a script against a project it never connects an
executor to. Threading one down would change a public signature and every caller
for no gain. openProjectForValidation is now the package's one way to do it.

The verification problem is a third distinct shape, after the read port and the
write port. Both validators FAIL OPEN: an unreadable project returns nil and
silences the rule, which is right — a check should not fail on something it
could not inspect — and it makes a broken reader silent. The rule stops firing,
which is indistinguishable from a project the rule does not apply to.

Coverage showed the risk was real: offlineProfilesIn, projectEntityFacts and
openProjectForValidation were all at 0.0%, because the existing tests exercised
only the pure helpers or passed an empty path. Now 78%, 72% and 100%.

For a fail-open path the test must assert the rule FIRES on a project that
should trigger it; asserting it stays quiet proves nothing, because quiet is
also the failure mode. Two setup details decided whether that test was real, and
the first attempt got both wrong: the fixture ships only an online navigation
profile, so the offline rule is inert on it either way and the test must seed
one — and Mendix fixes the legal profile names, so an invented one is refused by
the executor. The stock-fixture control runs first, so a reader that invented a
profile is caught before the positive assertion.

Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (544),
check-findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
TestRoundtripPage_MicroflowButtonWithCurrentObject has been failing on main
since #476 — on every branch cut from it, mine included. It is not caused by
the sdk/mpr ports in this PR; the full integration suite reproduces it at
648e346 and it is the only failure in the run.

It is also not a describe regression. mdlIdent quotes any identifier that does
not LEX as a bare identifier, running the real ANTLR lexer, and #476 added
`TARGET: T A R G E T;` to MDLLexer.g4 for the notify-workflow target clause.
The parameter named Target therefore began lexing as a keyword, and DESCRIBE
started emitting "Target": $currentObject. The output became more correct and
the exact-substring assertion went stale.

The input side did not break: TARGET was added to the non-reserved-keyword rule,
so scripts writing `Target:` unquoted still parse — which is why check-mdl's 544
scripts stayed green and only this one output assertion moved.

The assertion is now quoting-agnostic, which is what it was always for: the
roundtrip is about the mapping surviving, not about how the identifier is
spelled. Controlled by renaming the expected parameter to one that is absent —
it still fails, so it continues to detect a dropped mapping rather than passing
on anything.

Adding a token to the lexer silently reformats DESCRIBE output for every element
whose name matches it, and the grammar change and the broken test sit in
different packages with no compile-time link between them. Finding recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
…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>
Rewriting a microflow from its own DESCRIBE output moved every workflow
action ~800px right, the first one onto the end event. @position was
parsed and dropped: the visitor's setStatementAnnotations and the
builder's getStatementAnnotations were hand-written type switches with no
case for the eleven workflow statements or import/export mapping and
transform json. The setter also had an empty case for EnumSplitStmt (a
no-op in Go) and the getter lacked SendRestRequestStmt.

Both now delegate to reflection: the existing ast.StatementAnnotations
(mendixlabs#884) and a new ast.SetStatementAnnotations, so a statement type with an
Annotations field is covered when it is declared.

Test parses @position before 17 statement forms and checks the built
activity's position; with the fix reverted 17 subtests fail at the
builder default while the log control passes. Measured on i956 (11.13):
describe -> create or modify -> describe diffed every position before,
identical after.

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

Every CREATE MICROFLOW over MCP failed on 11.14: the Microflows$Microflow
constructor rejects untyped flows and a {type: …} returnType. It also takes
only the canvas now — object constructors declare x/y (plus caption or
loopType), and a create still carrying relativeMiddlePoint, action,
returnValue or a split/loop source is accepted with those silently dropped.

Detect the shape from the live constructor schema (serverInfo.version is
frozen at 1.0.0), send the skeleton in ped_create_document, and set each
object's action, return value, split condition and loop source plus
returnVariableName in one ped_update_document. Older servers keep the
single full create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against Studio Pro 11.14 over MCP, timer boundary events written
by mxcli were stored wrongly, while mxbuild 11.13 built all of them:

- Neither timer boundary event constructor has firstExecutionTime, so every
  delay was dropped (CE0126 "Missing value for parameter 'Timer'"). The
  delay is now set on the stored event after the write.
- The interrupting constructor rewrites the path's terminator. A jump
  outside a parallel split, stored as an End, is now put back. Paths Studio
  Pro rejects are refused before sending, naming the remedy: an interrupting
  timer that runs to its end, one inside a split not ending in a jump, and a
  non-interrupting one ending in a jump. isInsideOfParallelSplit is now sent
  for interrupting timers.
- A single user task's constructor drops its boundary events; timer events
  are now re-added along with notification events. An add to boundaryEvents
  does not keep order, so each event is added on its own and located by the
  persistentId that appeared, and delays are set at the stored position.
- ALTER WORKFLOW ... INSERT BOUNDARY EVENT applies the same rules.

Refusing up front matters: ped_update_document re-validates the whole
document, so one malformed path makes every later update fail.

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

`create or modify workflow` over MCP stored the flow's activities reversed,
left an old activity in and dropped a new one, and `alter workflow ...
replace activity` changed nothing, while every update reported SUCCESS.

One ped_update_document batch is not applied in the order sent. Measured
with raw PED calls on Studio Pro 11.14, every batch fits one rule: ops run
highest index first, and at one index the adds go in as a block, in op
order, before the removes, so a remove where something was just added
takes the added element. mxcli sent removes and adds for the same list in
one batch, with the adds reversed, index-less or at incrementing indices.

A list's new elements now go in at one index in the statement's order, and
the elements they replace are removed in a second update. This covers the
flow, event sub-processes and event handlers of a rewrite, and ALTER's
insert-after and replace. Adding first means a failure between the two
updates leaves duplicates rather than a workflow without its activities.

The fake PED gains a list simulator that implements the rule and replays
every measured batch, so the tests assert the stored order, not the ops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Port cmd/mxcli/docker and mdl/executor off sdk/mpr (Phase 4a, slices 2–3)
Picks up #478, which makes TestRoundtripPage_MicroflowButtonWithCurrentObject
quoting-agnostic (it broke when #476 made TARGET a lexer token).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(wiki): digest the 2026-09-15 findings into three bug-pattern pages
main (#476) now refuses `notify workflow` without a `target` (MDL-WF16,
CE0166 at build), so the two bare notify lines failed `make check-mdl` on
the PR merge ref. A target has to name a notification element of a real
workflow, which a standalone repro cannot supply; notify's position is
still covered by TestPositionAnnotationPlacesEveryActionStatement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five files — cmd_new, setup, serve, sync_java_deps, cmd_check_post_migration —
plus one deliberately refused. Importers 13 -> 8.

Every method they call is already on FullBackend; catalog.CatalogReader
documents itself as satisfied by it, and executor.NewContainerHierarchyFromBackend
already existed. openProjectReadOnly is now this package's one way in, and the
two inline ConnectReadOnly sites from the first slice use it too. setup.go had
TWO readers, not one — grep found the second where reading the first would have
left it behind.

cmd_extract_templates.go stays on sdk/mpr on purpose. It calls
FindCustomWidgetType, which is unimplemented on the codec backend: measured at
runtime it returns "not implemented on the model engine. This should be
unreachable." Porting it would have compiled and then failed for anyone
extracting a widget template.

The type error was the lucky part. RawType/RawObject are bson.D on sdk/mpr and
any on types.RawCustomWidgetType, so the port would not build — and the one-line
cast that silences that is the only thing between this and a runtime break. When
a port hits a type mismatch at a backend boundary, check whether the method is
implemented before reconciling the types. Note the direction: the unimplemented
method's error says "should be unreachable", and porting a caller to the backend
is exactly what makes it reachable.

One measurement trap, because it looked like a regression and was not: a
baseline diff of `check --post-migration` showed 50 lines disappearing. The
first run built and cached a catalog inside the project and the second reused
it. Each binary needs its own fresh copy of the fixture — a command that caches
into the project directory makes consecutive runs non-independent even when
nothing is written on purpose. With that fixed the run is 63 identical lines,
catalog build and legacy-widget scan included.

That scan prints "No legacy native widgets found" whether the project has none
or the reader handed it zero pages, so openProjectReadOnly has tests asserting
the reads these commands depend on return real data.

Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (544),
check-findings. Full repo-wide integration suite: exit 0, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
fix: keep @position on workflow and mapping actions across a rewrite
fix(mcp): create microflows against Studio Pro 11.14's skeleton constructor
The first version of the name-before-caption fix also scoped @n to the
chosen tier. CI caught it: mdl-examples/doctype-tests/24-workflow-examples.mdl
addresses a nested call activity as `ACT_Process@2` and failed with
"at position 2 not found (found 1 matches)". @n is an established positional
index over every activity whose name or caption matches, in DESCRIBE order.

Name precedence now applies only to an unqualified reference, which is the
only case the jump-caption ambiguity affected. Tests in both backends assert
`bugSplitJump@2` still reaches the jump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(workflow): resolve ALTER WORKFLOW activity refs by name before caption
Brings in #482 (resolve ALTER WORKFLOW activity refs by name before caption),
#481, #480 and the page roundtrip test fix that main's CI needed.

One conflict, in mcpWorkflowMutator.searchActivities: #482 hoisted the
activity's name into a variable on the same lines where this branch records
whether the match sits under a parallel split. Resolved to #482's `name`
plus this branch's `inSplit` field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestWFResolve_NameBeatsJumpCaption (#482) inserts a boundary event through a
fake PED that answers every boundaryEvents read with null. InsertBoundaryEvent
now finds the event it added by the persistentId that appeared, so against that
fake it failed with "expected one new element". The fake now keeps the lists,
like the other ALTER boundary-event tests; what the test asserts is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(mcp): timer boundary events and workflow list order over MCP
Port the rest of cmd/mxcli off sdk/mpr (Phase 4a, fourth slice)
The refusal from the fourth slice, resolved: cmd/mxcli/cmd_extract_templates.go
is now on the backend and NOTHING that ships in the binary imports sdk/mpr.
Importers 8 -> 7.

The implementation was never missing. modelsdk/mpr.Reader has had
FindCustomWidgetType, FindAllCustomWidgetTypes and the collectCustomWidgets
walker all along — and its version populates UnitName/WidgetName, which the one
I started writing from scratch would have left empty. Only the wiring onto
Backend was absent, which is exactly what the old error meant by "this should be
unreachable". Grep for an existing implementation before writing one: a method
listed in unimplemented_gen.go says nothing about whether the logic exists a
layer down.

A straight delegation then extracted 0 of 6 templates, reporting for each
widget: "[SKIP] Combo box: widget type is bson.D, want bson.D". modelsdk/mpr
builds RawType/RawObject with the v2 BSON driver while sdk/mpr and every caller
use v1 — unrelated Go types that print under the same name. types.RawCustomWidgetType
declares the fields `any`, so neither the compiler nor the error text can tell
them apart. An `any` field crossing an engine boundary can carry the right type
name and the wrong package; a cast written to silence that panics at runtime.

Fixed by converting at the boundary with the package's existing v2ToV1BSON, as
widget_pluggable_write.go already does for writes. All six templates extract
byte-for-byte identically to the pre-change binary, the 1.2MB datagrid.json
included, and reverting the conversion fails the new test with "RawType is
bson.D, want v1 bson.D".

unimplemented_gen.go still emits the stub after a method is implemented — the
generator writes a complete fallback set and Backend's own method shadows it —
so the list to update is unreachableUnimplemented in
unimplemented_reachability_test.go. It failed loudly on the stale entry, which
is how it was caught.

Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (547),
check-findings, and the full repo-wide integration suite: exit 0, no failures.

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

The XPath constraint-member check added in 3aa2ee0 rejected the stock
Administration.Account_Overview page:

  the constraint on Administration.Account names "System.UserRoles", which
  is neither an attribute nor an association of it

Administration.Account extends System.User and System.UserRoles is declared
from System.User, so it is an association of it by inheritance — and mx check
on the rejected page reports 0 errors, which settles it. Every inherited
association was a false positive, and separately so was every cross-module
one. Because that lands on a Marketplace module almost every app has, check
stopped being usable as a gate for anyone whose entities inherit.

associationTargetFrom matched the start entity against the association's two
ends by exact equality and scanned only dm.Associations. Its comment said the
specialisation case was deliberately not chased — "the cost of being wrong is
a false error on a working script" — which was sound while its only caller
treated false as silence. The new check treated the same false as evidence, so
the case the comment declined to chase became the finding. noteQualified did
carry a three-valued guard, but on the wrong axis: it asked whether the BASE
ENTITY was known, and Administration.Account is known.

The resolver is three-valued now, like memberResolution ten lines up:

  resolved   the start entity, or an ancestor of it, is an end
  missing    the module was read and has no association of that name
  notAnEnd   the chain was walked to its root and no link is an end
  unknown    a module, domain model or generalization could not be read

The start entity is matched through generalizationChain, a cross-module
association is found where it is actually stored (CrossAssociations, far end
held by name rather than by element ID), and only missing and notAnEnd are
reported. Resolving the hop also types the far end, so steps after an
inherited association are now checked at all.

Measured against ako/mxcli-sudoku on 11.13.0: the two rejected shapes now
pass check and build at 0 errors; a retrieve over an inherited association
followed by a bogus member is reported where it was silent before; and an
association the entity genuinely lacks — including a specialisation's
association named on its generalization — is still reported. Each half of the
fix was stubbed in turn and the new tests fail with the reported symptom.

Reported in ako/mxcli-sudoku FINDINGS #57.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
Implement FindCustomWidgetType on the codec backend (Phase 4a, fifth slice)
examples/ (5) and scripts/mprsnapshot (2). Importers 7 -> 0, completing Phase 4a
of docs/plans/2026-09-14-retire-legacy-engine.md.

Three shapes, not one. Four examples held the writer: mpr.NewWriter ->
Backend.Connect, Close -> Disconnect, and writer.Reader() dropped because the
backend is both halves — CreateEntity, CreateAssociation, DeleteEntity,
DeleteAssociation and CreatePage are identical in signature on both. Two held a
reader. The rest were the utilities GenerateID and BlobToUUID, and sdk/mpr's
copies are already one-line delegations to mdl/types, so pointing the call sites
at types is provably the same function rather than a same-named one — the
distinction the v1/v2 BSON trap in the previous slice turned on.

Verified per shape. mprsnapshot is a canonicalisation tool, so its output is the
evidence: identical in all four modes — default (2,870 lines), -refs (12,707),
-canon (374), -all (43,979). The write examples cannot be diffed that way, so
add_entities was run under both binaries and the resulting projects compared
with mprsnapshot: every element path and type matches, and once UUIDs are
normalised the only remaining difference is the domain model's content hash,
which digests bytes that embed those UUIDs. Fresh identities on new elements are
required, so that is the correct result rather than a discrepancy.

The count is now guarded. TestNothingImportsTheLegacyEngine parses every .go
file's imports and fails naming any file that imports sdk/mpr. Without it the
invariant lived in a plan document and a habit, and one import would restore
exactly the blind spot Phase 4a existed to close: a caller holding a concrete
reader is invisible to the unimplemented-method census.

A zero-count invariant needs two positive controls or it passes vacuously
forever, because every way of breaking it is silent — a wrong root, an
over-broad skip rule and an import-parsing mistake all report "0 importers". So
it asserts a plausible number of files was scanned (2,551) and that the detector
can see imports at all, by counting one the repo definitely has (mdl/backend,
120 files). Dropping a one-line file importing sdk/mpr into examples/ fails it
and names the file.

Gates: build, vet (incl. -tags integration), go test ./..., check-mdl (547),
check-findings, and the full repo-wide integration suite: exit 0, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
fix(check): resolve constraint associations through the generalization chain
Take the last seven off sdk/mpr — importers reach zero (Phase 4a complete)
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