Skip to content

Commit 1b343fa

Browse files
akoclaude
andcommitted
fix: moving an OWNER Both association's TO end leaves stale member access, and CE0001 associations are written (ako#819)
Three follow-ups from #802 (PR #809); two were still open on main. 1. `create or modify association` moving a cross-module OWNER Both association's TO end to another module (or entity) printed "Modified association" and mx check reported CE0066 "Entity access is out of date" at the old TO module and the new one. Changing only the owner through create or modify missed the TO module the same way. Cause: the cross-module OR MODIFY branch of execCreateAssociation (mdl/executor/cmd_associations.go) overwrote ChildRef/owner and reconciled nothing; the end-of-program reconcile covers the declaring module only. And ReconcileMemberAccesses kept the old TO entity's entry even when its module was reconciled, because a reference to another module's existing association was preserved on faith (#758's rule for inheritance across modules). Fix: the branch reads the old TO module before overwriting and reconciles declaring, old TO and new TO modules (reconcileModulesAccess). The reconcile treats an existing foreign association as stale on an entity whose generalization chain never leaves its module — its members are then fully known (own + incoming OWNER Both). Inheritance across modules stays preserved (control test). 2. Between a persistent and a non-persistent entity, Mendix accepts only FROM the non-persistent end with owner Default; `owner both`, a persistent FROM end, and `alter association … set owner both` all passed check and exec and failed CE0001. Now refused as MDL-ASSOC01 in `check` (ValidateProgram, script declarations first, then the project) and in `exec` (create, create or modify, alter set owner both — also under --no-check), through one predicate (associationPersistenceViolation) and one stored-ends lookup (storedAssociationEnds). 3. GRANT accepting the TO-side member of a cross-module OWNER Both association already works on main (74bc820): `grant read (Number, Lead_Order) on entity Shop.Order …` writes the member, mx check 0 errors. No change. Verified on a blank 11.13.0 app with security on, mx check before (baseline binary) / after: TO end moved to another module 2 -> 0 (CE0066 at both TO modules), within the module 1 -> 0, owner Default->Both via create or modify 1 -> 0; `update security` repairs the move broken by the baseline binary. CE0001 matrix: NP->P both, P->NP default, P->NP both, cross-module NP->P both, ReferenceSet NP->P both and alter set owner both were each 1 CE0001 before and are refused now with the project untouched (0 errors); NP->P default is still written (0 errors). No skill or mdl-examples script trips the new check. Tests revert-proven (fix reverted, predicate stubbed): every new test fails, the controls pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 957fe86 commit 1b343fa

12 files changed

Lines changed: 683 additions & 21 deletions

File tree

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"}

‎.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
@@ -298,7 +298,7 @@ func init() {
298298
"many-to-one", "many-to-many", "foreign key",
299299
"owner", "delete behavior",
300300
},
301-
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.",
301+
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.",
302302
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);",
303303
SeeAlso: []string{"domain-model.association.create", "domain-model.association.anchor", "domain-model.association.delete-behavior"},
304304
})

‎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-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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
mdl 1;
2+
-- ============================================================================
3+
-- ako/mxcli#819 — association member access under OWNER Both.
4+
--
5+
-- 1. `create or modify association` moving the TO end of a cross-module OWNER
6+
-- Both association to an entity of another module reconciled only the
7+
-- declaring module. The old TO entity kept its member entry and the new one
8+
-- never got one:
9+
-- before: [CE0066] "Entity access is out of date…" at Domain model of
10+
-- module 'Access819Shop'; `describe entity Access819Shop.Order`
11+
-- still shows `read (Number, Lead_Order)` for the Viewer. Run as
12+
-- its own script after the setup, also CE0066 at 'Access819Hr'
13+
-- (here the end-of-program reconcile covers Hr, which the grants
14+
-- touched).
15+
-- after: 0 errors; "Reconciled … in module Access819Shop after modifying
16+
-- the association" (and Access819Hr), and the Viewer's rule is
17+
-- `read (Number)`.
18+
-- The old TO entity's entry used to survive even `update security`: the
19+
-- reconcile kept any reference to another module's association on faith.
20+
--
21+
-- 2. Between a persistent and a non-persistent entity Mendix allows only FROM
22+
-- the non-persistent one with owner Default; anything else is CE0001
23+
-- "An association between a persistable entity and a non-persistable entity
24+
-- must start in the non-persistable entity and have owner 'Default'."
25+
-- `mxcli check` and `exec` now refuse it (MDL-ASSOC01). Kept out of this
26+
-- script so it checks clean — each of these is refused:
27+
--
28+
-- create association Access819Shop.Cart_Order
29+
-- from Access819Shop.Cart to Access819Shop.Order owner both;
30+
-- create association Access819Shop.Order_Cart
31+
-- from Access819Shop.Order to Access819Shop.Cart;
32+
-- alter association Access819Shop.Cart_Customer set owner both;
33+
--
34+
-- 3. GRANT already accepts the TO-side member of a cross-module OWNER Both
35+
-- association by its bare name — `read (Number, Lead_Order)` below.
36+
--
37+
-- Access rules are only checked with security on.
38+
-- ============================================================================
39+
40+
alter app security ( SecurityLevel: prototype );
41+
42+
create module Access819Crm;
43+
create module role Access819Crm.User;
44+
create module Access819Shop;
45+
create module role Access819Shop.User;
46+
create module role Access819Shop.Viewer;
47+
create module Access819Hr;
48+
create module role Access819Hr.User;
49+
50+
@position(100, 100)
51+
create persistent entity Access819Crm.Lead ( Name: string(200) );
52+
@position(100, 100)
53+
create persistent entity Access819Shop.Order ( Number: string(20) );
54+
@position(400, 100)
55+
create persistent entity Access819Shop.Customer ( Name: string(200) );
56+
@position(100, 300)
57+
create non-persistent entity Access819Shop.Cart ( Label: string(50) );
58+
@position(100, 100)
59+
create persistent entity Access819Hr.Employee ( Name: string(200) );
60+
61+
create association Access819Crm.Lead_Order
62+
from Access819Crm.Lead to Access819Shop.Order
63+
owner both;
64+
-- The shape Mendix allows across the persistence boundary.
65+
create association Access819Shop.Cart_Customer
66+
from Access819Shop.Cart to Access819Shop.Customer;
67+
68+
grant create, delete, read *, write * on entity Access819Crm.Lead to Access819Crm.User;
69+
grant create, delete, read *, write * on entity Access819Shop.Order to Access819Shop.User;
70+
grant read (Number, Lead_Order) on entity Access819Shop.Order to Access819Shop.Viewer;
71+
grant create, delete, read *, write * on entity Access819Shop.Customer to Access819Shop.User;
72+
grant create, delete, read *, write * on entity Access819Shop.Cart to Access819Shop.User;
73+
grant create, delete, read *, write * on entity Access819Hr.Employee to Access819Hr.User;
74+
75+
-- Move the TO end from Access819Shop.Order to Access819Hr.Employee.
76+
create or modify association Access819Crm.Lead_Order
77+
from Access819Crm.Lead to Access819Hr.Employee
78+
owner both;
79+
80+
describe entity Access819Shop.Order;
81+
describe entity Access819Hr.Employee;

‎mdl/backend/modelsdk/domainmodel_security_write.go‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -662,12 +662,14 @@ func (b *Backend) ReconcileMemberAccesses(unitID model.ID, moduleName string) (i
662662
rule.RemoveMemberAccesses(i)
663663
changed = true
664664
case !assocRefBelongsTo(assocRef, moduleName) &&
665-
elsewhere.staleForeignRef(assocRef):
666-
// A foreign reference its module no longer declares (CE1613).
667-
// Kept before ako/mxcli#1449, so `drop entity` on the FROM end
668-
// of a cross-module OWNER Both association, and `drop
669-
// association` on one, left the entry on the TO end for good —
670-
// and `update security` reported nothing to do.
665+
elsewhere.staleForeignRef(assocRef, foreignAnc):
666+
// A foreign reference that is positively stale: its module no
667+
// longer declares it (CE1613), or this entity cannot be one of
668+
// its members (CE0066). See staleForeignRef. Kept before
669+
// ako/mxcli#1449 / #819, so `drop entity` on the FROM end of a
670+
// cross-module OWNER Both association, `drop association` on
671+
// one, and moving its TO end all left the entry for good — and
672+
// `update security` reported nothing to do.
671673
rule.RemoveMemberAccesses(i)
672674
changed = true
673675
case !assocRefBelongsTo(assocRef, moduleName):
@@ -854,21 +856,28 @@ func (b *Backend) scanProjectAssociations(moduleName string) (*projectAssociatio
854856

855857
// staleForeignRef reports whether a MemberAccess naming an association declared
856858
// in ANOTHER module is positively stale on an entity of the module being
857-
// reconciled.
859+
// reconciled. foreignAnc is foreignAncestorModule's answer for that entity: ""
860+
// when its whole generalization chain lies in its own module.
858861
//
859862
// The reconcile used to keep every such reference, because the one it was
860863
// written for — an association inherited from a generalization in another module
861-
// — cannot be validated without walking that chain. One case can be decided
862-
// without it: the declaring module exists and no longer declares the
863-
// association. Then it is a member of nothing, and mx check reports CE1613 "The
864-
// selected association … no longer exists". That is what `drop entity` on the
865-
// FROM end of a cross-module OWNER Both association, or `drop association` on
866-
// one, leaves on the TO end (ako/mxcli#1449).
864+
// — cannot be validated without walking that chain. Two cases can be decided
865+
// without it, and both were reported (ako/mxcli#1449, ako/mxcli#819):
866+
//
867+
// - The declaring module exists and no longer declares the association. It is a
868+
// member of nothing; mx check reports CE1613 "The selected association … no
869+
// longer exists". This is what `drop entity` on the FROM end of a cross-module
870+
// OWNER Both association, or `drop association` on one, leaves on the TO end.
871+
// - The association still exists, but the entity's generalization chain never
872+
// leaves its module. Then the only way a foreign association is a member of it
873+
// is as the TO end under OWNER Both, which `incoming` enumerates — so one the
874+
// reconcile did not collect is not a member: CE0066 "Entity access is out of
875+
// date". This is what moving the TO end of such an association leaves behind.
867876
//
868877
// A reference into a module the project does not store — System, whose domain
869878
// model is virtual, or one that is simply absent — is still preserved, and so is
870-
// any association that still exists.
871-
func (p *projectAssociations) staleForeignRef(ref string) bool {
879+
// an existing association on an entity that inherits from another module.
880+
func (p *projectAssociations) staleForeignRef(ref, foreignAnc string) bool {
872881
idx := strings.LastIndex(ref, ".")
873882
if idx < 0 {
874883
return false
@@ -877,7 +886,10 @@ func (p *projectAssociations) staleForeignRef(ref string) bool {
877886
if !known {
878887
return false
879888
}
880-
return !names[strings.ToLower(ref[idx+1:])]
889+
if !names[strings.ToLower(ref[idx+1:])] {
890+
return true
891+
}
892+
return foreignAnc == ""
881893
}
882894

883895
// foreignAncestorModule returns the module of the first generalization in ent's

0 commit comments

Comments
 (0)