Skip to content

Commit 302f7b3

Browse files
authored
Merge pull request #1689 from ako/fix/1449-drop-entity-association-access
fix: access rules out of step after drop entity and association moves (#1449, #819)
2 parents e3c4b2c + 1b343fa commit 302f7b3

18 files changed

Lines changed: 1211 additions & 52 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"area": "mdl/executor", "date": "2026-10-25", "symptom": "ako/mxcli#819 (follow-ups of #802), measured on a blank 11.13.0 app with security on. (1) `create or modify association Crm.Lead_Order from Crm.Lead to Hr.Employee owner both` — moving a cross-module OWNER Both association's TO end from Shop.Order — printed \"Modified association\" and mx check reported CE0066 \"Entity access is out of date\" at the domain models of both Shop (old TO) and Hr (new TO); moving it to another entity of the same module, or only changing the owner through create or modify, left CE0066 at the TO module. `update security` repaired Hr but not Shop. (2) `create association … from <non-persistent> to <persistent> owner both` passed check and exec and failed CE0001 \"An association between a persistable entity and a non-persistable entity must start in the non-persistable entity and have owner 'Default'.\"; so did a persistent FROM end under owner Default, and `alter association … set owner both`. (3) GRANT naming the TO-side member of a cross-module OWNER Both association (`read (Number, Lead_Order)`) already worked on main — fixed by 74bc820b7, verified 0 errors.", "cause": "(1) The cross-module OR MODIFY branch of execCreateAssociation overwrote ChildRef and owner and reconciled nothing itself; the end-of-program reconcile covered only the declaring module. And ReconcileMemberAccesses kept the old TO entity's entry even when its module was reconciled: a reference to another module's association was preserved on faith (the #758 rule for associations inherited across modules), and this one still exists, just not as a member. (2) No check or exec guard modelled CE0001.", "file": "mdl/executor/cmd_associations.go (OR MODIFY cross branch reconciles declaring, old TO and new TO modules via reconcileModulesAccess; refusals on create and alter set owner both); mdl/executor/validate_association_persistence.go (associationPersistenceViolation, projectEntityPersistence, storedAssociationEnds, ValidateAssociationPersistence — wired into ValidateProgram); mdl/backend/modelsdk/domainmodel_security_write.go (staleForeignRef: an existing foreign association on an entity whose generalization chain never leaves its module is stale unless collected as an incoming OWNER Both)", "insight": "The issue named one half of each defect. The move left a stale entry on the OLD TO module and a missing one on the NEW TO module — read the old ChildRef before overwriting it — and the CE0001 rule is two-sided: mxbuild's message (and a 4-cell measurement: NP->P default ok, NP->P both, P->NP default, P->NP both all CE0001) says FROM must be the non-persistable end AND owner Default, so refusing only `owner both` would have left the persistent-FROM case writing a broken model. Measure the whole matrix before scoping a refusal to the reported cell. For the reconcile, 'cannot be checked' was only true when the entity inherits from another module: with a module-local generalization chain the full member set is known (own + incoming OWNER Both), so anything else foreign is stale; the cross-module-inheritance control stays green. Persistability for the refusal comes from the backend (specializations resolved from their root, mendixlabs/mxcli#1600) in exec and from the script's own declarations first in check, through one predicate. A single bug-test script under-reports the move on the old binary: grants track Hr, so the end-of-program reconcile repairs the new TO module and only the old one shows.", "refs": ["ako/mxcli#819", "ako/mxcli#802", "ako/mxcli#1449"], "ce": ["CE0066", "CE0001"], "rules": ["MDL-ASSOC01"], "test": "mdl/executor/association_persistence_819_test.go (TestAssociationPersistenceViolation_MatchesMxbuild, TestValidateProgram_RefusesOwnerBothAcrossPersistenceFromTheScript, TestExecAssociation_RefusesOwnerBothAcrossPersistence, TestValidateAssociationPersistence_ResolvesTheProject, TestCreateOrModifyAssociation_MovingTheToEndReconcilesBothToModules); mdl/backend/modelsdk/reconcile_foreign_stale_assoc_test.go (TestReconcile_DropsAForeignAssociationWhoseToEndMoved, control TestReconcile_KeepsAForeignAssociationInheritedAcrossModules); mdl-examples/bug-tests/access-819-association-owner-both-member.mdl"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"area": "mdl/executor", "date": "2026-10-25", "symptom": "`drop entity Shop.Customer` reported \"Dropped entity\" and mx check then failed CE1613 \"The selected association 'Shop.Order_Customer' no longer exists.\" at Access rule of entity 'Shop.Order'; the same across modules (`drop entity Crm.Contact` leaving Shop.Order_Contact in Shop.Order's rule), and for the FROM end of a cross-module OWNER Both association (`drop entity Crm.Lead` leaving Crm.Lead_Order on its TO entity Shop.Order). `drop association Crm.Lead_Order` left the same entry, and `update security` printed \"All entity access rules are up to date\" over it (ako/mxcli#1449, mendixlabs/mxcli#1551).", "cause": "Two gaps. (1) execDropEntity called DeleteEntity, which cascades the associations away in every module, and reconciled nothing; execDropAssociation reconciled only the DECLARING module, though under OWNER Both a cross-module association is a member of its TO entity in another module. (2) ReconcileMemberAccesses kept every association reference qualified by another module (the 'preserve what cannot be checked' branch for associations inherited across modules, #758), so even a reconcile of the right module could not remove an entry for an association that no longer exists anywhere.", "file": "mdl/executor/cmd_entities.go (associationMemberModules, read before the delete), mdl/executor/cmd_associations.go (reconcileModulesAccess, now behind create/alter/drop association and drop entity); mdl/backend/modelsdk/domainmodel_security_write.go (scanProjectAssociations records what every module declares; staleForeignRef)", "insight": "Which modules to reconcile is not 'the module written': a rule naming an association lives on the FROM entity (the declaring module, or ANOTHER module when the dropped entity is the TO end of a cross-module association) and under OWNER Both on the TO entity too — and that set must be read BEFORE the delete, while the associations exist. 'Preserve what cannot be checked' was right for its case and had quietly widened to every foreign reference: existence in the declaring module is checkable from the same scan the reconcile already did for incoming associations, and 'not declared' is a positive finding, not an unknown. Keep the boundary narrow — System (virtual domain model) and absent modules stay preserved, and an existing association on a specialization of another module's entity stays (control test). A single MDL script is a weak repro here: the end-of-program reconcile repairs the same-module and FROM-side cases on the old binary, so only the OWNER Both one shows in a combined script — run the drop as its own script to see all three. Measured on mxbuild 11.13.0: baseline 1 CE1613 per drop, fixed 0; `update security` on a project broken by the old binary now repairs it.", "refs": ["ako/mxcli#1449", "mendixlabs/mxcli#1551", "ako/mxcli#802"], "ce": ["CE1613"], "test": "mdl/executor/cmd_entities_drop_reconcile_test.go (TestExecDropEntity_ReconcilesEveryModuleHoldingAMemberOfItsAssociations, TestExecDropAssociation_CrossModuleReconcilesTheToModule); mdl/backend/modelsdk/reconcile_foreign_stale_assoc_test.go (TestReconcile_DropsAForeignAssociationThatNoLongerExists, control TestReconcile_KeepsAForeignAssociationInheritedAcrossModules); mdl-examples/bug-tests/access-1449-drop-entity-association-member.mdl"}

‎.claude/skills/mendix/generate-domain-model/reference/syntax.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,12 @@ both is refused as **MDL085**.
535535
- `Parent` - Only parent (TO) entity owns
536536
- `Child` - Only child (FROM) entity owns
537537

538+
> **Persistent ↔ non-persistent: FROM the non-persistent entity, owner `default`.**
539+
> That is the only shape Mendix accepts between a persistable and a
540+
> non-persistable entity; a persistent FROM end or `owner both` is **CE0001**, and
541+
> `mxcli check` refuses it as **MDL-ASSOC01** (`alter association … set owner
542+
> both` too).
543+
538544
> **Use `default` ownership for a normal to-one reference.** Reserve `owner both`
539545
> for a `ReferenceSet` (many-to-many). On a plain `type reference`, `owner both`
540546
> makes the association navigable **to-one from *both* sides** — so the reverse

‎cmd/mxcli/syntax/features_domain_model.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ func init() {
306306
"many-to-one", "many-to-many", "foreign key",
307307
"owner", "delete behavior",
308308
},
309-
Syntax: "[@anchor(from: (x, y), to: (x, y))]\nCREATE [OR MODIFY] ASSOCIATION Module.Name\n FROM Module.FromEntity TO Module.ToEntity\n [TYPE Reference|ReferenceSet]\n [OWNER Default|Both]\n [STORAGE COLUMN|TABLE]\n [ON DELETE CASCADE|RESTRICT|SET NULL [ERROR MESSAGE 'text']];\n-- Documentation: the /** … */ doc comment before the statement\n-- (COMMENT 'text' is its deprecated alias, MDL-DEPR100).\nALTER ASSOCIATION Module.Name SET ANCHOR FROM (x, y) TO (x, y);\nDROP ASSOCIATION Module.Name;\n\nOR MODIFY: updates type/owner/delete behavior in-place, preserves UUID.\n\n@anchor sets the LINE ANCHORS — where the connector attaches to each entity box\nin the domain model editor — as a PERCENTAGE of the box (0..100, whole numbers).\n`from` is the FROM entity's box, `to` the TO entity's: (0, 50) is the middle of\nthe left edge, (100, 50) the right, (50, 100) the bottom centre. Omitting an end\nPRESERVES what is stored, so a CREATE OR MODIFY about something else never\nflattens a hand-tuned line. Cross-module associations have no anchors.\n\nDROP reconciles the entity access rules that named the association, and is\nREFUSED while a message definition still exposes it (that one cannot be\nreconciled -- removing the member would change a published contract). The\nrefusal prints the `alter message definition ... drop member` statement for\neach definition, ready to run.",
309+
Syntax: "[@anchor(from: (x, y), to: (x, y))]\nCREATE [OR MODIFY] ASSOCIATION Module.Name\n FROM Module.FromEntity TO Module.ToEntity\n [TYPE Reference|ReferenceSet]\n [OWNER Default|Both]\n [STORAGE COLUMN|TABLE]\n [ON DELETE CASCADE|RESTRICT|SET NULL [ERROR MESSAGE 'text']];\n-- Documentation: the /** … */ doc comment before the statement\n-- (COMMENT 'text' is its deprecated alias, MDL-DEPR100).\nALTER ASSOCIATION Module.Name SET ANCHOR FROM (x, y) TO (x, y);\nDROP ASSOCIATION Module.Name;\n\nOR MODIFY: updates type/owner/delete behavior in-place, preserves UUID.\n\n@anchor sets the LINE ANCHORS — where the connector attaches to each entity box\nin the domain model editor — as a PERCENTAGE of the box (0..100, whole numbers).\n`from` is the FROM entity's box, `to` the TO entity's: (0, 50) is the middle of\nthe left edge, (100, 50) the right, (50, 100) the bottom centre. Omitting an end\nPRESERVES what is stored, so a CREATE OR MODIFY about something else never\nflattens a hand-tuned line. Cross-module associations have no anchors.\n\nPersistent <-> non-persistent: FROM the non-persistent entity, OWNER Default.\nA persistent FROM end or OWNER Both there is CE0001 and is refused\n(MDL-ASSOC01), on CREATE and on ALTER ... SET OWNER Both.\n\nDROP reconciles the entity access rules that named the association, and is\nREFUSED while a message definition still exposes it (that one cannot be\nreconciled -- removing the member would change a published contract). The\nrefusal prints the `alter message definition ... drop member` statement for\neach definition, ready to run.",
310310
Example: "mdl 1;\n-- Many-to-one\nCREATE ASSOCIATION Shop.Order_Customer\n FROM Shop.Order TO Shop.Customer\n TYPE Reference\n OWNER Default\n ON DELETE SET NULL;\n\n-- Many-to-many\nCREATE ASSOCIATION Shop.Product_Tag\n FROM Shop.Product TO Shop.Tag\n TYPE ReferenceSet\n OWNER Both;\n\n-- Line leaving the bottom of Order and entering the top of Customer\n@anchor(from: (50, 100), to: (50, 0))\nCREATE ASSOCIATION Shop.Order_Customer\n FROM Shop.Order TO Shop.Customer;\n\n-- Retune the line without restating the association\nALTER ASSOCIATION Shop.Order_Customer SET ANCHOR FROM (0, 54) TO (100, 54);",
311311
SeeAlso: []string{"domain-model.association.create", "domain-model.association.anchor", "domain-model.association.delete-behavior"},
312312
})

‎docs-site/src/reference/domain-model/create-association.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ A documentation comment (`/** ... */`) placed before the statement is preserved
6363
**OWNER**
6464
: Which side can modify the association. Defaults to `Default` if omitted.
6565

66+
: Between a persistent and a non-persistent entity, Mendix allows one shape only: `FROM` the non-persistent entity, with owner `Default`. A persistent `FROM` end or `OWNER Both` there fails the build with CE0001, so `mxcli check` and `exec` refuse it (MDL-ASSOC01). `ALTER ASSOCIATION … SET OWNER Both` is refused the same way.
67+
68+
: Under `OWNER Both` the association is a member of the TO entity too, so its access rules get an entry for it — in the TO entity's module, for a cross-module association. `CREATE OR MODIFY` that moves the TO end, or changes the owner, reconciles the old and the new TO module's rules.
69+
6670
**ON DELETE**
6771
: What happens to associated objects when a FROM-side object is deleted. If omitted, references are kept (the default Mendix behavior).
6872

‎docs-site/src/reference/domain-model/drop-association.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
`DROP ASSOCIATION` removes an association (relationship) between two entities from the domain model. The entities themselves are not affected -- only the relationship is removed.
1010

11-
After dropping an association, any microflows, pages, or access rules that traverse it will become invalid. Use `LIST IMPACT OF Module.AssociationName` to check references before dropping.
11+
The access rules that named the association are reconciled: the entry is removed from the FROM entity's rules and, for an `OWNER Both` association, from the TO entity's — which for a cross-module association are in another module. A message definition that still exposes the association blocks the drop, naming the member to remove first.
12+
13+
Microflows and pages that traverse the association become invalid. Use `LIST IMPACT OF Module.AssociationName` to check references before dropping.
1214

1315
## Parameters
1416

‎docs-site/src/reference/domain-model/drop-entity.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88

99
`DROP ENTITY` removes an entity from a module's domain model. The entity and all of its attributes, validation rules, indexes, and access rules are deleted.
1010

11-
Associations that reference the dropped entity are **not** automatically removed. Drop those separately with `DROP ASSOCIATION` before or after dropping the entity to avoid dangling references.
11+
Every association with the dropped entity at either end is removed with it — including a cross-module association declared in another module. The access rules of the entities at the other end are reconciled afterwards, in whichever module they live, so no rule is left naming an association that no longer exists (Mendix would reject that with CE1613):
12+
13+
```
14+
Reconciled 1 access rule(s) in module Shop after dropping the entity
15+
Dropped entity: Crm.Contact
16+
```
17+
18+
Microflows, pages and other documents that use the entity or one of those associations are not changed.
1219

1320
Use `LIST IMPACT OF Module.EntityName` to check which microflows, pages, and associations reference the entity before dropping it.
1421

@@ -32,10 +39,7 @@ mdl 1;
3239
-- Check what references this entity
3340
LIST IMPACT OF Sales.OldEntity;
3441

35-
-- Drop related association first
36-
DROP ASSOCIATION Sales.OldEntity_Customer;
37-
38-
-- Then drop the entity
42+
-- Drops the entity and its associations, and reconciles the access rules
3943
DROP ENTITY Sales.OldEntity;
4044
```
4145

‎docs-wiki/bug-patterns/access-rule-reconciliation.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Access Rules Are Reconciled, Not Appended
33
category: bug-pattern
4-
last-synced: ced830e0
4+
last-synced: 1ccc9383e
55
covers:
66
- mdl/backend
77
sources:
@@ -50,6 +50,24 @@ local walk cannot see. Dropping what the walk cannot confirm produced
5050
it no longer lists. The safe default is to carry an unconfirmable member through
5151
rather than to prune it.
5252

53+
**"Cannot be checked" is a claim, and it widens if nobody re-tests it.** The
54+
preserve rule was written for one case — an association inherited from a
55+
generalization in another module — and was implemented as "keep every
56+
reference qualified by another module". That also kept the entry for a
57+
cross-module `OWNER Both` association after its FROM entity was dropped, after
58+
the association was dropped, and after its TO end moved: CE1613 or CE0066, with
59+
`update security` reporting nothing to do. Whether the declaring module still
60+
declares the association was answerable all along, from the same scan the
61+
reconcile already made for incoming associations. **Before preserving, ask
62+
which part is genuinely unknowable, and preserve only that** — and keep a
63+
control for the case the rule was written for.
64+
65+
**The rule to fix is often not in the module written.** A member entry lives on
66+
the FROM entity and, under `OWNER Both`, on the TO entity — another module for a
67+
cross-module association. A statement that removes or moves an association has
68+
to name every module holding one of those rules, and must collect them *before*
69+
the write, while the associations still exist to be read.
70+
5371
**Under-reporting access is the read-side twin.** A restricted page reported as
5472
having "no roles" is the same class seen from the query side, and it is the shape
5573
most likely to be believed, because "no roles" reads like a finding rather than a
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
mdl 1;
2+
-- ============================================================================
3+
-- ako/mxcli#1449 (upstream mendixlabs/mxcli#1551) — `drop entity` left the
4+
-- association's member entry in the access rule of the entity at the OTHER end.
5+
--
6+
-- Dropping an entity removes every association with it at either end, in other
7+
-- modules too, but reconciled no access rule. `drop association` did reconcile
8+
-- — only the declaring module, though, so a cross-module OWNER Both
9+
-- association left its TO entity's entry behind as well. And the reconcile
10+
-- itself kept any reference to another module's association on faith, so even
11+
-- `update security` printed "All entity access rules are up to date" over it.
12+
--
13+
-- mxcli exec this-file -p app.mpr ; mx check app.mpr
14+
-- before: [CE1613] "The selected association 'Access1449Crm.Lead_Order'
15+
-- no longer exists." at Access rule of entity 'Access1449Shop.Order'
16+
-- (with the drops run as their own script, one CE1613 per drop)
17+
-- after: 0 errors; `describe entity Access1449Shop.Order` still grants
18+
-- read *, write * and names none of the three associations
19+
--
20+
-- Access rules are only checked with security on.
21+
-- ============================================================================
22+
23+
alter app security ( SecurityLevel: prototype );
24+
25+
create module Access1449Shop;
26+
create module role Access1449Shop.User;
27+
create module Access1449Crm;
28+
create module role Access1449Crm.User;
29+
30+
@position(100, 100)
31+
create persistent entity Access1449Shop.Customer ( Name: string(200) );
32+
@position(400, 100)
33+
create persistent entity Access1449Shop.Order ( Number: string(20) );
34+
@position(100, 100)
35+
create persistent entity Access1449Crm.Contact ( Name: string(200) );
36+
@position(400, 100)
37+
create persistent entity Access1449Crm.Lead ( Name: string(200) );
38+
39+
-- Same module: the entry is on Order, FROM end.
40+
create association Access1449Shop.Order_Customer
41+
from Access1449Shop.Order to Access1449Shop.Customer;
42+
-- Cross-module: the entry is on Order, in a module that is not the one of the
43+
-- entity dropped below.
44+
create association Access1449Shop.Order_Contact
45+
from Access1449Shop.Order to Access1449Crm.Contact;
46+
-- Cross-module, OWNER Both: a member of the TO end too, so Order carries an
47+
-- entry for an association its own module does not declare.
48+
create association Access1449Crm.Lead_Order
49+
from Access1449Crm.Lead to Access1449Shop.Order
50+
owner both;
51+
52+
grant create, delete, read *, write * on entity Access1449Shop.Customer to Access1449Shop.User;
53+
grant create, delete, read *, write * on entity Access1449Shop.Order to Access1449Shop.User;
54+
grant create, delete, read *, write * on entity Access1449Crm.Contact to Access1449Crm.User;
55+
grant create, delete, read *, write * on entity Access1449Crm.Lead to Access1449Crm.User;
56+
57+
-- Each drop removes one association that Access1449Shop.Order's rule names.
58+
drop entity Access1449Shop.Customer;
59+
drop entity Access1449Crm.Contact;
60+
drop entity Access1449Crm.Lead;
61+
62+
describe entity Access1449Shop.Order;

0 commit comments

Comments
 (0)