diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-system-enumeration-flow-parameter-written-object-type.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-system-enumeration-flow-parameter-written-object-type.json new file mode 100644 index 0000000000..cc48819ac0 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-21-system-enumeration-flow-parameter-written-object-type.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-21", "symptom": "Over --mcp, `create microflow M.X ($T: System.UserType) begin … end;` fails: `ped_create_document … {\"/parameters/0/entity\":\"Reference with qualified name System.UserType of type DomainModels$Entity not found.\"}`. A module enumeration (`Rules.RuleResult`) works. On the local engines the same script reports success and DESCRIBE prints a plausible bare `$T: System.UserType`, but mx check gives `[CE1613] \"The selected entity 'System.UserType' no longer exists.\"` for the parameter and for a `returns System.DeviceType`. The explicit `$T: enum System.UserType` (what DESCRIBE emits for an enumeration) was refused: \"enumeration 'System.UserType' not found for parameter 'T'\"", "cause": "The visitor types every bare qualified name in a flow parameter or return position as TypeEntity. buildMicroflowFromStmt / buildNanoflowFromStmt / execCreateRule fall back from entity to enumeration only after the entity lookup misses, and skip that lookup entirely for the System module (isBuiltinModuleEntity: its entities are not stored in the project), so a System enumeration kept the entity guess and was written as DataTypes$ObjectType with Entity \"System.UserType\". The explicit form failed separately: findEnumeration reads ListEnumerations, which never contains the System module's enumerations", "fix": "settleSystemEnumeration retypes a TypeEntity whose qualified name is in meta.SystemEnumerations to TypeEnumeration before validation, at all six sites (microflow, nanoflow, rule × parameter, return type); isKnownEnumeration accepts a System enumeration where findEnumeration cannot see it. A System entity (System.User) stays an object type", "file": "mdl/executor/cmd_microflows_create.go (settleSystemEnumeration, isKnownEnumeration), mdl/executor/cmd_microflows_build.go, mdl/executor/cmd_rules_create.go", "insight": "It was never an MCP bug: the local engines wrote the same wrong object type and only mx check (CE1613) noticed; MCP surfaced it because PED resolves references at write time. DESCRIBE hid it — it renders an enumeration type as `enum X` and an object type as bare `X`, so a bare `System.UserType` in describe output IS the symptom, not proof of a correct write. Dump the stored DataTypes$*Type (`mxcli bson dump --type microflow`) rather than reading describe. The general lesson for references into System: 'do not judge what you cannot list' covers existence, not kind — whether a name is an entity or an enumeration changes the stored shape, so it must still be decided, and modelsdk/meta.SystemEnumerations is the list to decide it from. Verified live over --mcp on Studio Pro 11.15.0-rc.4 (ped_read_document reads back DataTypes$EnumerationType for parameter and return type), with a stubbed-fix control reproducing the PED error", "refs": ["ako/mxcli#1534"], "ce": ["CE1613"]} diff --git a/mdl-examples/bug-tests/mcp-system-enumeration-parameter.mdl b/mdl-examples/bug-tests/mcp-system-enumeration-parameter.mdl new file mode 100644 index 0000000000..4afde05542 --- /dev/null +++ b/mdl-examples/bug-tests/mcp-system-enumeration-parameter.mdl @@ -0,0 +1,48 @@ +mdl 1; +-- ============================================================================ +-- A System enumeration named bare in a flow signature is an enumeration +-- ============================================================================ +-- +-- `$T: System.UserType` used to be written as DataTypes$ObjectType with Entity +-- "System.UserType" on every engine. The visitor types every bare qualified +-- name in a parameter or return position as an entity; the executor's +-- entity-then-enumeration fallback was skipped for the System module, whose +-- entities are not in the project to look up. The local engines wrote the +-- object type without complaint, and DESCRIBE rendered it as a plausible bare +-- `System.UserType` (an enumeration renders as `enum System.UserType`). +-- Studio Pro's PED refused it over --mcp: +-- +-- {"/parameters/0/entity":"Reference with qualified name System.UserType +-- of type DomainModels$Entity not found."} +-- +-- The explicit `enum System.UserType` spelling — what DESCRIBE emits — was +-- refused outright ("enumeration 'System.UserType' not found"), because the +-- System module's enumerations are not in ListEnumerations. +-- +-- Verified over --mcp against Studio Pro 11.15.0-rc.4: both microflows create, +-- and ped_read_document reads back DataTypes$EnumerationType for the +-- parameters and the return type. The System.User parameter is the control: it +-- must stay an object type. +-- ============================================================================ + +CREATE MODULE BugSysEnumParam; + +CREATE OR MODIFY MICROFLOW BugSysEnumParam.ACT_ByUserType ($T: System.UserType) +RETURNS System.DeviceType +BEGIN + IF $T = System.UserType.Internal THEN + RETURN System.DeviceType.Desktop; + ELSE + RETURN System.DeviceType.Phone; + END IF; +END; + +CREATE OR MODIFY MICROFLOW BugSysEnumParam.ACT_ExplicitEnum ($T: enum System.UserType) +BEGIN + LOG INFO NODE 'BugSysEnumParam' 'explicit enum parameter'; +END; + +CREATE OR MODIFY MICROFLOW BugSysEnumParam.ACT_SystemEntity ($U: System.User) +BEGIN + LOG INFO NODE 'BugSysEnumParam' 'System entity parameter stays an object'; +END; diff --git a/mdl/executor/cmd_microflows_build.go b/mdl/executor/cmd_microflows_build.go index 43f0b7d978..04f6cfc06c 100644 --- a/mdl/executor/cmd_microflows_build.go +++ b/mdl/executor/cmd_microflows_build.go @@ -318,6 +318,8 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b // Validate and add parameters for i, p := range s.Parameters { + p.Type = settleSystemEnumeration(p.Type) + s.Parameters[i].Type = p.Type // Validate entity references for List and Entity types. // Built-in modules (e.g. System) are not stored in the MPR domain models; // their types are serialized by qualified name and resolved at runtime. @@ -337,7 +339,7 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b } // Validate enumeration references for Enumeration types if p.Type.Kind == ast.TypeEnumeration && p.Type.EnumRef != nil { - if found := findEnumeration(ctx, p.Type.EnumRef.Module, p.Type.EnumRef.Name); found == nil { + if !isKnownEnumeration(ctx, p.Type.EnumRef.Module, p.Type.EnumRef.Name) { return nil, mdlerrors.NewNotFoundMsg("enumeration", p.Type.EnumRef.Module+"."+p.Type.EnumRef.Name, fmt.Sprintf("enumeration '%s.%s' not found for parameter '%s'", p.Type.EnumRef.Module, p.Type.EnumRef.Name, p.Name)) } @@ -356,6 +358,7 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b // Validate and set return type if s.ReturnType != nil { + s.ReturnType.Type = settleSystemEnumeration(s.ReturnType.Type) // Validate entity references for return type. // Built-in modules (e.g. System) are not stored in the MPR domain models; // their types are serialized by qualified name and resolved at runtime. @@ -373,7 +376,7 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b } // Validate enumeration references for return type if s.ReturnType.Type.Kind == ast.TypeEnumeration && s.ReturnType.Type.EnumRef != nil { - if found := findEnumeration(ctx, s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name); found == nil { + if !isKnownEnumeration(ctx, s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name) { return nil, mdlerrors.NewNotFoundMsg("enumeration", s.ReturnType.Type.EnumRef.Module+"."+s.ReturnType.Type.EnumRef.Name, fmt.Sprintf("enumeration '%s.%s' not found for return type", s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name)) } @@ -630,6 +633,8 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui // Validate and add parameters for i, p := range s.Parameters { + p.Type = settleSystemEnumeration(p.Type) + s.Parameters[i].Type = p.Type if p.Type.EntityRef != nil && !isBuiltinModuleEntity(p.Type.EntityRef.Module) { entityID := entityResolver(*p.Type.EntityRef) if entityID == "" { @@ -645,7 +650,7 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui } } if p.Type.Kind == ast.TypeEnumeration && p.Type.EnumRef != nil { - if found := findEnumeration(ctx, p.Type.EnumRef.Module, p.Type.EnumRef.Name); found == nil { + if !isKnownEnumeration(ctx, p.Type.EnumRef.Module, p.Type.EnumRef.Name) { return nil, mdlerrors.NewNotFoundMsg("enumeration", p.Type.EnumRef.Module+"."+p.Type.EnumRef.Name, fmt.Sprintf("enumeration '%s.%s' not found for parameter '%s'", p.Type.EnumRef.Module, p.Type.EnumRef.Name, p.Name)) } @@ -664,6 +669,7 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui // Validate and set return type if s.ReturnType != nil { + s.ReturnType.Type = settleSystemEnumeration(s.ReturnType.Type) if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { @@ -677,7 +683,7 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui } } if s.ReturnType.Type.Kind == ast.TypeEnumeration && s.ReturnType.Type.EnumRef != nil { - if found := findEnumeration(ctx, s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name); found == nil { + if !isKnownEnumeration(ctx, s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name) { return nil, mdlerrors.NewNotFoundMsg("enumeration", s.ReturnType.Type.EnumRef.Module+"."+s.ReturnType.Type.EnumRef.Name, fmt.Sprintf("enumeration '%s.%s' not found for return type", s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name)) } diff --git a/mdl/executor/cmd_microflows_create.go b/mdl/executor/cmd_microflows_create.go index 664416679f..313e6056ec 100644 --- a/mdl/executor/cmd_microflows_create.go +++ b/mdl/executor/cmd_microflows_create.go @@ -10,6 +10,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/meta" ) // isBuiltinModuleEntity returns true for modules whose entities are defined @@ -20,6 +21,38 @@ func isBuiltinModuleEntity(moduleName string) bool { return moduleName == "System" } +// systemEnumerations holds the qualified name of every enumeration the System +// module declares. Like its entities they are not stored in the project, so +// ListEnumerations never returns them and findEnumeration cannot see them. +var systemEnumerations = func() map[string]bool { + set := make(map[string]bool, len(meta.SystemEnumerations)) + for _, e := range meta.SystemEnumerations { + set[e.Name] = true + } + return set +}() + +// isKnownEnumeration reports whether module.name is an enumeration — one of the +// project's, or one of the System module's. +func isKnownEnumeration(ctx *ExecContext, module, name string) bool { + return systemEnumerations[module+"."+name] || findEnumeration(ctx, module, name) != nil +} + +// settleSystemEnumeration retypes a bare System enumeration in a flow +// parameter or return type. The visitor types every bare qualified name there +// as an entity, and the entity-then-enumeration fallback the callers apply is +// skipped for the System module (its entities are not in the project to look +// up), so without this `$T: System.UserType` was written as an ObjectType with +// Entity "System.UserType" — accepted by the local engines, refused by Studio +// Pro ("Reference with qualified name System.UserType of type +// DomainModels$Entity not found"). +func settleSystemEnumeration(dt ast.DataType) ast.DataType { + if dt.Kind == ast.TypeEntity && dt.EntityRef != nil && systemEnumerations[dt.EntityRef.Module+"."+dt.EntityRef.Name] { + return ast.DataType{Kind: ast.TypeEnumeration, EnumRef: dt.EntityRef} + } + return dt +} + // execCreateMicroflow handles CREATE MICROFLOW statements. // loadRestServices returns all consumed REST services, or nil if no backend. func loadRestServices(ctx *ExecContext) ([]*model.ConsumedRestService, error) { diff --git a/mdl/executor/cmd_rules_create.go b/mdl/executor/cmd_rules_create.go index d7af12b895..30c20d41e4 100644 --- a/mdl/executor/cmd_rules_create.go +++ b/mdl/executor/cmd_rules_create.go @@ -163,6 +163,8 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { // Validate and add parameters for i, p := range s.Parameters { + p.Type = settleSystemEnumeration(p.Type) + s.Parameters[i].Type = p.Type if p.Type.EntityRef != nil && !isBuiltinModuleEntity(p.Type.EntityRef.Module) { entityID := entityResolver(*p.Type.EntityRef) if entityID == "" { @@ -178,7 +180,7 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { } } if p.Type.Kind == ast.TypeEnumeration && p.Type.EnumRef != nil { - if found := findEnumeration(ctx, p.Type.EnumRef.Module, p.Type.EnumRef.Name); found == nil { + if !isKnownEnumeration(ctx, p.Type.EnumRef.Module, p.Type.EnumRef.Name) { return mdlerrors.NewNotFoundMsg("enumeration", p.Type.EnumRef.Module+"."+p.Type.EnumRef.Name, fmt.Sprintf("enumeration '%s.%s' not found for parameter '%s'", p.Type.EnumRef.Module, p.Type.EnumRef.Name, p.Name)) } @@ -197,6 +199,7 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { // Validate and set return type if s.ReturnType != nil { + s.ReturnType.Type = settleSystemEnumeration(s.ReturnType.Type) if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { @@ -210,7 +213,7 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { } } if s.ReturnType.Type.Kind == ast.TypeEnumeration && s.ReturnType.Type.EnumRef != nil { - if found := findEnumeration(ctx, s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name); found == nil { + if !isKnownEnumeration(ctx, s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name) { return mdlerrors.NewNotFoundMsg("enumeration", s.ReturnType.Type.EnumRef.Module+"."+s.ReturnType.Type.EnumRef.Name, fmt.Sprintf("enumeration '%s.%s' not found for return type", s.ReturnType.Type.EnumRef.Module, s.ReturnType.Type.EnumRef.Name)) } diff --git a/mdl/executor/system_enum_flow_type_test.go b/mdl/executor/system_enum_flow_type_test.go new file mode 100644 index 0000000000..1e6639eae2 --- /dev/null +++ b/mdl/executor/system_enum_flow_type_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// writtenMicroflowFromMDL parses one CREATE MICROFLOW statement and returns the +// microflow handed to the backend. Parsing (rather than building the AST by +// hand) is the point: the visitor types every bare qualified name in a +// parameter or return position as an entity, and the executor is what has to +// tell an enumeration apart. +func writtenMicroflowFromMDL(t *testing.T, src string) *microflows.Microflow { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse error: %v", errs[0]) + } + stmt, ok := prog.Statements[0].(*ast.CreateMicroflowStmt) + if !ok { + t.Fatalf("expected CreateMicroflowStmt, got %T", prog.Statements[0]) + } + ctx, written := microflowWriteProbe(t, nil, model.ID("module-1")) + if err := execCreateMicroflow(ctx, stmt); err != nil { + t.Fatalf("CREATE MICROFLOW failed: %v", err) + } + if *written == nil { + t.Fatal("no microflow was written") + } + return *written +} + +func wantEnumerationType(t *testing.T, what string, dt microflows.DataType, qn string) { + t.Helper() + et, ok := dt.(*microflows.EnumerationType) + if !ok { + t.Fatalf("%s: got %T (%s), want *microflows.EnumerationType %s", what, dt, dt.GetTypeName(), qn) + } + if et.EnumerationQualifiedName != qn { + t.Fatalf("%s: enumeration = %q, want %q", what, et.EnumerationQualifiedName, qn) + } +} + +// TestCreateMicroflow_SystemEnumerationParameter: a System enumeration named +// bare in a parameter used to be written as DataTypes$ObjectType with Entity +// "System.UserType" — on every engine. The System module is not stored in the +// project, so the entity-then-enumeration lookup was skipped for it outright +// and the visitor's entity guess stood. The local engines wrote that without +// complaint (and describe rendered it as a plausible bare `System.UserType`); +// Studio Pro's PED refused it over --mcp: "Reference with qualified name +// System.UserType of type DomainModels$Entity not found." +func TestCreateMicroflow_SystemEnumerationParameter(t *testing.T) { + mf := writtenMicroflowFromMDL(t, `create microflow MyModule.MF ($T: System.UserType) +begin +end;`) + if len(mf.Parameters) != 1 { + t.Fatalf("got %d parameters, want 1", len(mf.Parameters)) + } + wantEnumerationType(t, "parameter $T", mf.Parameters[0].Type, "System.UserType") +} + +func TestCreateMicroflow_SystemEnumerationReturnType(t *testing.T) { + mf := writtenMicroflowFromMDL(t, `create microflow MyModule.MF () +returns System.DeviceType +begin + return System.DeviceType.Desktop; +end;`) + wantEnumerationType(t, "return type", mf.ReturnType, "System.DeviceType") +} + +// The explicit spelling is what DESCRIBE emits for an enumeration parameter, so +// it must round-trip. It used to be refused outright: the System module's +// enumerations are not in ListEnumerations, so validation reported +// "enumeration 'System.UserType' not found". +func TestCreateMicroflow_ExplicitSystemEnumerationParameter(t *testing.T) { + mf := writtenMicroflowFromMDL(t, `create microflow MyModule.MF ($T: enum System.UserType) +begin +end;`) + wantEnumerationType(t, "parameter $T", mf.Parameters[0].Type, "System.UserType") +} + +// Control: a System *entity* must stay an object type, or the fix has merely +// flipped the guess. +func TestCreateMicroflow_SystemEntityParameterStaysObject(t *testing.T) { + mf := writtenMicroflowFromMDL(t, `create microflow MyModule.MF ($U: System.User) +begin +end;`) + ot, ok := mf.Parameters[0].Type.(*microflows.ObjectType) + if !ok { + t.Fatalf("parameter $U: got %T, want *microflows.ObjectType", mf.Parameters[0].Type) + } + if ot.EntityQualifiedName != "System.User" { + t.Fatalf("parameter $U: entity = %q, want System.User", ot.EntityQualifiedName) + } +} + +// The nanoflow builder carries its own copy of the parameter resolution, so it +// needs its own case. +func TestCreateNanoflow_SystemEnumerationParameter(t *testing.T) { + prog, errs := visitor.Build(`create nanoflow MyModule.NF ($D: System.DeviceType) +begin +end;`) + if len(errs) > 0 { + t.Fatalf("parse error: %v", errs[0]) + } + stmt, ok := prog.Statements[0].(*ast.CreateNanoflowStmt) + if !ok { + t.Fatalf("expected CreateNanoflowStmt, got %T", prog.Statements[0]) + } + const moduleID = model.ID("module-1") + var written *microflows.Nanoflow + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{{BaseElement: model.BaseElement{ID: moduleID}, Name: "MyModule"}}, nil + }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + return &model.Module{BaseElement: model.BaseElement{ID: moduleID}, Name: "MyModule"}, nil + }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + CreateNanoflowFunc: func(nf *microflows.Nanoflow) error { + written = nf + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + if err := execCreateNanoflow(ctx, stmt); err != nil { + t.Fatalf("CREATE NANOFLOW failed: %v", err) + } + if written == nil { + t.Fatal("no nanoflow was written") + } + wantEnumerationType(t, "parameter $D", written.Parameters[0].Type, "System.DeviceType") +}