This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new fd60b2073 WW-3476 Add package-scoped constants (#2005)
fd60b2073 is described below

commit fd60b2073583a67d3cccb22c89ebed407d198af8
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 11 10:14:46 2026 +0200

    WW-3476 Add package-scoped constants (#2005)
    
    * WW-3476 docs: add design spec for package-scoped constants
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 docs: add implementation plan for package-scoped constants
    
    Wire the provider like other pluggable core services (struts-beans.xml,
    StrutsBeanSelectionProvider alias, struts.scopedConstantProvider).
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 feat(config): store scoped constants on PackageConfig
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 feat(config): add struts-7.5.dtd with <scoped-constant>
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 feat(config): reject unregistered scoped constants at startup
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 feat(config): add ScopedConstantProvider resolving package 
overrides
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 test(config): pin ScopedConstantProvider wiring on the Dispatcher 
path
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 test(config): drop the xwork prefix from scoped-constant fixtures
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 fix(config): skip ScopableConstants bean names without an instance
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 refactor(config): resolve Configuration and scopable names from 
the container once
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 refactor(config): address Sonar findings on scoped constants
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 test(config): name scoped-constant fixtures struts-*.xml
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 fix(mock): return an empty set from MockContainer.getInstanceNames
    
    Matches the Container contract ContainerImpl follows; callers iterate the
    result without a null check.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 docs(config): document ScopedConstantProvider as a bean-selection 
extension point
    
    Lists it in the StrutsBeanSelectionProvider extension-point table, adds
    struts.scopedConstantProvider to default.properties, and pins that the
    constant selects a custom bean.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 refactor(config): move StrutsScopedConstantProvider to 
config.providers
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-3476 refactor(config): complete the StrutsScopedConstantProvider move
    
    c93980593 committed only the renames; this adds the package declarations,
    imports and the struts-beans.xml class name.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/struts2/StrutsConstants.java   |    8 +
 .../ScopableConstants.java}                        |   53 +-
 .../ScopedConstantProvider.java}                   |   55 +-
 .../config/StrutsBeanSelectionProvider.java        |    7 +
 .../config/StrutsXmlConfigurationProvider.java     |    3 +-
 .../struts2/config/entities/PackageConfig.java     |   43 +
 .../struts2/config/impl/DefaultConfiguration.java  |   25 +
 .../StrutsDefaultConfigurationProvider.java        |    3 +
 .../providers/StrutsScopedConstantProvider.java    |   70 ++
 .../providers/XmlDocConfigurationProvider.java     |   17 +
 .../org/apache/struts2/mock/MockContainer.java     |    3 +-
 .../org/apache/struts2/default.properties          |    3 +
 core/src/main/resources/struts-7.5.dtd             |  164 +++
 core/src/main/resources/struts-beans.xml           |    3 +
 .../config/CustomScopedConstantProvider.java}      |   45 +-
 .../struts2/config/SampleScopableConstants.java}   |   43 +-
 .../struts2/config/ScopableConstantsTest.java}     |   52 +-
 .../config/ScopedConstantProviderWiringTest.java   |   39 +
 .../config/ScopedConstantsValidationTest.java      |   89 ++
 .../struts2/config/SecondScopableConstants.java}   |   43 +-
 .../struts2/config/entities/PackageConfigTest.java |   86 ++
 .../StrutsScopedConstantProviderTest.java          |  131 +++
 .../XmlConfigurationProviderPackagesTest.java      |   31 +
 .../struts-scoped-constants-duplicate.xml          |   30 +
 .../struts-scoped-constants-inheritance.xml        |   34 +
 .../providers/struts-scoped-constants-old-dtd.xml  |   29 +
 ...ruts-scoped-constants-abstract-unregistered.xml |   32 +
 .../struts-scoped-constants-custom-provider.xml    |   30 +
 .../config/struts-scoped-constants-no-registry.xml |   29 +
 .../config/struts-scoped-constants-provider.xml    |   46 +
 .../config/struts-scoped-constants-registered.xml  |   32 +
 .../config/struts-scoped-constants-two-beans.xml   |   35 +
 .../struts-scoped-constants-unregistered.xml       |   32 +
 .../plans/2026-10-10-WW-3476-scoped-constants.md   | 1074 ++++++++++++++++++++
 .../2026-10-10-WW-3476-scoped-constants-design.md  |  182 ++++
 35 files changed, 2368 insertions(+), 233 deletions(-)

diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java 
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 3d3fef328..4c9ca1719 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -628,6 +628,14 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_PARAMETER_ALLOWLISTER = 
"struts.parameterAllowlister";
 
+    /**
+     * Name of the Struts bean implementing {@link 
org.apache.struts2.config.ScopedConstantProvider}, selected by
+     * {@link org.apache.struts2.config.StrutsBeanSelectionProvider}.
+     *
+     * @since 7.5.0
+     */
+    public static final String STRUTS_SCOPED_CONSTANT_PROVIDER = 
"struts.scopedConstantProvider";
+
     /**
      * Enables evaluation of OGNL expressions
      *
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java 
b/core/src/main/java/org/apache/struts2/config/ScopableConstants.java
similarity index 52%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to core/src/main/java/org/apache/struts2/config/ScopableConstants.java
index a03ec2d67..de25a41a8 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/main/java/org/apache/struts2/config/ScopableConstants.java
@@ -16,49 +16,32 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.mock;
+package org.apache.struts2.config;
 
 import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
 
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.Set;
 
 /**
- * Mock implementation to be used in unittests
+ * Declares constants a package may override with {@code <scoped-constant>}. 
Register implementations as named beans;
+ * the scopable set is the union over all of them.
+ *
+ * @since 7.5.0
  */
-public class MockContainer implements Container {
-
-    public void inject(Object o) {
-
-    }
-
-    public <T> T inject(Class<T> implementation) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type, String name) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type) {
-        return null;
-    }
-
-    public Set<String> getInstanceNames(Class<?> type) {
-        return null;
-    }
+public interface ScopableConstants {
 
-    public void setScopeStrategy(Scope.Strategy scopeStrategy) {
+    Set<String> getNames();
 
+    static Set<String> collectNames(Container container) {
+        Set<String> names = new HashSet<>();
+        for (String beanName : 
container.getInstanceNames(ScopableConstants.class)) {
+            ScopableConstants scopableConstants = 
container.getInstance(ScopableConstants.class, beanName);
+            if (scopableConstants != null) {
+                names.addAll(scopableConstants.getNames());
+            }
+        }
+        return Collections.unmodifiableSet(names);
     }
-
-    public void removeScopeStrategy() {
-
-    }
-
-    @Override
-    public void destroy() {
-        // no-op in mock
-    }
-
 }
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java 
b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
similarity index 50%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
index a03ec2d67..53e655483 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
@@ -16,49 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.mock;
-
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
-
-import java.util.Set;
+package org.apache.struts2.config;
 
 /**
- * Mock implementation to be used in unittests
+ * Resolves a scopable constant for the current action invocation: the 
package's {@code <scoped-constant>}, inherited
+ * through {@code extends}, or else the global constant.
+ *
+ * @since 7.5.0
  */
-public class MockContainer implements Container {
-
-    public void inject(Object o) {
-
-    }
-
-    public <T> T inject(Class<T> implementation) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type, String name) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type) {
-        return null;
-    }
-
-    public Set<String> getInstanceNames(Class<?> type) {
-        return null;
-    }
-
-    public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
-    }
-
-    public void removeScopeStrategy() {
-
-    }
-
-    @Override
-    public void destroy() {
-        // no-op in mock
-    }
-
+public interface ScopedConstantProvider {
+
+    /**
+     * @param name a constant registered through a {@link ScopableConstants} 
bean
+     * @return the scoped value, or the global value ({@code null} when there 
is none)
+     * @throws IllegalArgumentException when {@code name} is not scopable
+     */
+    String getValue(String name);
 }
diff --git 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
index b0dda639b..866bad44f 100644
--- 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
@@ -359,6 +359,12 @@ import org.apache.struts2.webjars.WebJarUrlProvider;
  *     <td>singleton</td>
  *     <td>Provides access to resource bundles used to localise messages 
(since 2.5.11)</td>
  *   </tr>
+ *   <tr>
+ *     <td>org.apache.struts2.config.ScopedConstantProvider</td>
+ *     <td>struts.scopedConstantProvider</td>
+ *     <td>singleton</td>
+ *     <td>Resolves package-scoped constants declared with 
&lt;scoped-constant&gt; (since 7.5.0)</td>
+ *   </tr>
  * </table>
  * <p>
  * <!-- END SNIPPET: extensionPoints -->
@@ -454,6 +460,7 @@ public class StrutsBeanSelectionProvider extends 
AbstractBeanSelectionProvider {
         alias(ProxyService.class, StrutsConstants.STRUTS_PROXYSERVICE, 
builder, props, Scope.SINGLETON);
         alias(ParameterAuthorizer.class, 
StrutsConstants.STRUTS_PARAMETER_AUTHORIZER, builder, props, Scope.SINGLETON);
         alias(ParameterAllowlister.class, 
StrutsConstants.STRUTS_PARAMETER_ALLOWLISTER, builder, props, Scope.SINGLETON);
+        alias(ScopedConstantProvider.class, 
StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER, builder, props, 
Scope.SINGLETON);
 
         alias(SecurityMemberAccess.class, 
StrutsConstants.STRUTS_MEMBER_ACCESS, builder, props, Scope.PROTOTYPE);
         alias(OgnlGuard.class, StrutsConstants.STRUTS_OGNL_GUARD, builder, 
props, Scope.SINGLETON);
diff --git 
a/core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java
 
b/core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java
index 12a57a3a8..6b0a9dce9 100644
--- 
a/core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java
@@ -51,7 +51,8 @@ public class StrutsXmlConfigurationProvider extends 
XmlConfigurationProvider {
             "-//Apache Software Foundation//DTD Struts Configuration 2.3//EN", 
"struts-2.3.dtd",
             "-//Apache Software Foundation//DTD Struts Configuration 2.5//EN", 
"struts-2.5.dtd",
             "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN", 
"struts-6.0.dtd",
-            "-//Apache Software Foundation//DTD Struts Configuration 6.5//EN", 
"struts-6.5.dtd");
+            "-//Apache Software Foundation//DTD Struts Configuration 6.5//EN", 
"struts-6.5.dtd",
+            "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN", 
"struts-7.5.dtd");
 
     private File baseDir = null;
     private final String filename;
diff --git 
a/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java 
b/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java
index 391ead116..88e196192 100644
--- a/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java
+++ b/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java
@@ -59,6 +59,8 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
     protected boolean isFinal = false; // a final package is unextendable
     protected boolean needsRefresh;
     protected boolean strictMethodInvocation = true;
+    private Map<String, String> scopedConstants;
+    private Map<String, String> allScopedConstants;
 
     protected PackageConfig(String name) {
         this.name = name;
@@ -69,6 +71,8 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
         resultTypeConfigs = new LinkedHashMap<>();
         globalExceptionMappingConfigs = new ArrayList<>();
         parents = new ArrayList<>();
+        scopedConstants = new LinkedHashMap<>();
+        allScopedConstants = Collections.emptyMap();
     }
 
     protected PackageConfig(PackageConfig orig) {
@@ -90,6 +94,8 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
         this.parents = new ArrayList<>(orig.parents);
         this.location = orig.location;
         this.strictMethodInvocation = orig.strictMethodInvocation;
+        this.scopedConstants = new LinkedHashMap<>(orig.scopedConstants);
+        this.allScopedConstants = orig.allScopedConstants;
     }
 
     public boolean isAbstract() {
@@ -189,6 +195,22 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
         return retMap;
     }
 
+    /**
+     * @return scoped constants declared by this package itself, without 
inherited ones
+     * @since 7.5.0
+     */
+    public Map<String, String> getScopedConstants() {
+        return scopedConstants;
+    }
+
+    /**
+     * @return scoped constants in effect for this package: inherited ones, 
overridden by its own
+     * @since 7.5.0
+     */
+    public Map<String, String> getAllScopedConstants() {
+        return allScopedConstants;
+    }
+
     /**
      * returns the List of all the ExceptionMappingConfigs available in the 
current package.
      * ExceptionMappingConfigs defined in ancestor packages will be included 
in this list.
@@ -391,6 +413,7 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
         if (!Objects.equals(globalExceptionMappingConfigs, 
that.globalExceptionMappingConfigs))
             return false;
         if (!Objects.equals(parents, that.parents)) return false;
+        if (!Objects.equals(scopedConstants, that.scopedConstants)) return 
false;
         if (!Objects.equals(defaultInterceptorRef, that.defaultInterceptorRef))
             return false;
         if (!Objects.equals(defaultActionRef, that.defaultActionRef))
@@ -412,6 +435,7 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
         result = 31 * result + (resultTypeConfigs != null ? 
resultTypeConfigs.hashCode() : 0);
         result = 31 * result + (globalExceptionMappingConfigs != null ? 
globalExceptionMappingConfigs.hashCode() : 0);
         result = 31 * result + (parents != null ? parents.hashCode() : 0);
+        result = 31 * result + (scopedConstants != null ? 
scopedConstants.hashCode() : 0);
         result = 31 * result + (defaultInterceptorRef != null ? 
defaultInterceptorRef.hashCode() : 0);
         result = 31 * result + (defaultActionRef != null ? 
defaultActionRef.hashCode() : 0);
         result = 31 * result + (defaultResultType != null ? 
defaultResultType.hashCode() : 0);
@@ -605,6 +629,14 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
             return this;
         }
 
+        /**
+         * @since 7.5.0
+         */
+        public Builder addScopedConstant(String name, String value) {
+            target.scopedConstants.put(name, value);
+            return this;
+        }
+
         public Builder location(Location loc) {
             target.location = loc;
             return this;
@@ -655,11 +687,22 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
             target.resultTypeConfigs = 
Collections.unmodifiableMap(target.resultTypeConfigs);
             target.globalExceptionMappingConfigs = 
Collections.unmodifiableList(target.globalExceptionMappingConfigs);
             target.parents = Collections.unmodifiableList(target.parents);
+            target.scopedConstants = 
Collections.unmodifiableMap(target.scopedConstants);
+            target.allScopedConstants = mergeScopedConstants(target);
             PackageConfig result = target;
             target = new PackageConfig(result);
             return result;
         }
 
+        private static Map<String, String> mergeScopedConstants(PackageConfig 
config) {
+            Map<String, String> merged = new LinkedHashMap<>();
+            for (PackageConfig parent : config.parents) {
+                merged.putAll(parent.getAllScopedConstants());
+            }
+            merged.putAll(config.scopedConstants);
+            return Collections.unmodifiableMap(merged);
+        }
+
         @Override
         public String toString() {
             return "[BUILDER] " + target.toString();
diff --git 
a/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java 
b/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
index 53bf20dc8..4f908bfb6 100644
--- 
a/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
+++ 
b/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
@@ -36,6 +36,7 @@ import org.apache.struts2.config.FileManagerFactoryProvider;
 import org.apache.struts2.config.FileManagerProvider;
 import org.apache.struts2.config.PackageProvider;
 import org.apache.struts2.config.RuntimeConfiguration;
+import org.apache.struts2.config.ScopableConstants;
 import org.apache.struts2.config.entities.ActionConfig;
 import org.apache.struts2.config.entities.InterceptorMapping;
 import org.apache.struts2.config.entities.PackageConfig;
@@ -449,6 +450,8 @@ public class DefaultConfiguration implements Configuration {
      * @throws ConfigurationException in case of any configuration errors
      */
     protected synchronized RuntimeConfiguration buildRuntimeConfiguration() 
throws ConfigurationException {
+        validateScopedConstants();
+
         Map<String, Map<String, ActionConfig>> namespaceActionConfigs = new 
LinkedHashMap<>();
         Map<String, String> namespaceConfigs = new LinkedHashMap<>();
 
@@ -488,6 +491,28 @@ public class DefaultConfiguration implements Configuration 
{
                 Collections.unmodifiableMap(namespaceConfigs), matcher, 
appendNamedParameters, fallbackToEmptyNamespace);
     }
 
+    /**
+     * @since 7.5.0
+     */
+    protected void validateScopedConstants() throws ConfigurationException {
+        Set<String> scopableNames = ScopableConstants.collectNames(container);
+        for (PackageConfig packageConfig : packageContexts.values()) {
+            for (String name : packageConfig.getScopedConstants().keySet()) {
+                if (!scopableNames.contains(name)) {
+                    throw new ConfigurationException(String.format("Package 
[%s] declares scoped constant [%s], which is not scopable. %s",
+                            packageConfig.getName(), name, 
describeScopable(scopableNames)), packageConfig);
+                }
+            }
+        }
+    }
+
+    private static String describeScopable(Set<String> scopableNames) {
+        if (scopableNames.isEmpty()) {
+            return "No constants are scopable in this configuration.";
+        }
+        return "Scopable constants: " + new TreeSet<>(scopableNames) + ".";
+    }
+
     private void setDefaultResults(Map<String, ResultConfig> results, 
PackageConfig packageContext) {
         String defaultResult = packageContext.getFullDefaultResultType();
 
diff --git 
a/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
index 40d757586..736af6c91 100644
--- 
a/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
@@ -26,6 +26,7 @@ import org.apache.struts2.UnknownHandlerManager;
 import org.apache.struts2.config.Configuration;
 import org.apache.struts2.config.ConfigurationException;
 import org.apache.struts2.config.ConfigurationProvider;
+import org.apache.struts2.config.ScopedConstantProvider;
 import org.apache.struts2.config.impl.DefaultConfiguration;
 import org.apache.struts2.conversion.NullHandler;
 import org.apache.struts2.conversion.impl.ArrayConverter;
@@ -130,6 +131,8 @@ public class StrutsDefaultConfigurationProvider implements 
ConfigurationProvider
 
                 .factory(UnknownHandlerManager.class, 
DefaultUnknownHandlerManager.class, Scope.SINGLETON)
 
+                .factory(ScopedConstantProvider.class, 
StrutsScopedConstantProvider.class, Scope.SINGLETON)
+
                 // silly workarounds for ognl since there is no way to flush 
its caches
                 .factory(PropertyAccessor.class, List.class.getName(), 
XWorkListPropertyAccessor.class, Scope.SINGLETON)
                 .factory(PropertyAccessor.class, ArrayList.class.getName(), 
XWorkListPropertyAccessor.class, Scope.SINGLETON)
diff --git 
a/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java
 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java
new file mode 100644
index 000000000..ebfc968f5
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.config.providers;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ActionProxy;
+import org.apache.struts2.config.Configuration;
+import org.apache.struts2.config.ScopableConstants;
+import org.apache.struts2.config.ScopedConstantProvider;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.apache.struts2.inject.Container;
+import org.apache.struts2.inject.Inject;
+
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * @since 7.5.0
+ */
+public class StrutsScopedConstantProvider implements ScopedConstantProvider {
+
+    private Container container;
+    private Configuration configuration;
+    private Set<String> scopableNames;
+
+    @Inject
+    public void setContainer(Container container) {
+        this.container = container;
+        this.configuration = container.getInstance(Configuration.class);
+        this.scopableNames = ScopableConstants.collectNames(container);
+    }
+
+    @Override
+    public String getValue(String name) {
+        if (!scopableNames.contains(name)) {
+            throw new IllegalArgumentException(String.format(
+                    "Constant [%s] is not scopable; register it through a %s 
bean", name, ScopableConstants.class.getName()));
+        }
+        return currentPackage()
+                .map(packageConfig -> 
packageConfig.getAllScopedConstants().get(name))
+                .orElseGet(() -> container.getInstance(String.class, name));
+    }
+
+    private Optional<PackageConfig> currentPackage() {
+        ActionContext context = ActionContext.getContext();
+        ActionInvocation invocation = context != null ? 
context.getActionInvocation() : null;
+        ActionProxy proxy = invocation != null ? invocation.getProxy() : null;
+        if (proxy == null || proxy.getConfig() == null) {
+            return Optional.empty();
+        }
+        return 
Optional.ofNullable(configuration.getPackageConfig(proxy.getConfig().getPackageName()));
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java
 
b/core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java
index da44b71f6..b6c606e66 100644
--- 
a/core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java
@@ -424,6 +424,8 @@ public abstract class XmlDocConfigurationProvider 
implements ConfigurationProvid
 
         LOG.debug("Loaded {}", newPackage);
 
+        loadScopedConstants(newPackage, packageElement);
+
         // add result types (and default result) to this package
         addResultTypes(newPackage, packageElement);
 
@@ -839,6 +841,21 @@ public abstract class XmlDocConfigurationProvider 
implements ConfigurationProvid
         }
     }
 
+    /**
+     * @since 7.5.0
+     */
+    protected void loadScopedConstants(PackageConfig.Builder packageContext, 
Element packageElement) {
+        Set<String> declared = new HashSet<>();
+        iterateChildrenByTagName(packageElement, "scoped-constant", 
constantElement -> {
+            String name = constantElement.getAttribute("name");
+            if (!declared.add(name)) {
+                throw new ConfigurationException(format("Package [%s] declares 
scoped constant [%s] more than once",
+                        packageContext.getName(), name), constantElement);
+            }
+            packageContext.addScopedConstant(name, 
constantElement.getAttribute("value"));
+        });
+    }
+
     /**
      * Load all the global results for this package from the XML element.
      *
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java 
b/core/src/main/java/org/apache/struts2/mock/MockContainer.java
index a03ec2d67..209b8a123 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/main/java/org/apache/struts2/mock/MockContainer.java
@@ -21,6 +21,7 @@ package org.apache.struts2.mock;
 import org.apache.struts2.inject.Container;
 import org.apache.struts2.inject.Scope;
 
+import java.util.Collections;
 import java.util.Set;
 
 /**
@@ -45,7 +46,7 @@ public class MockContainer implements Container {
     }
 
     public Set<String> getInstanceNames(Class<?> type) {
-        return null;
+        return Collections.emptySet();
     }
 
     public void setScopeStrategy(Scope.Strategy scopeStrategy) {
diff --git a/core/src/main/resources/org/apache/struts2/default.properties 
b/core/src/main/resources/org/apache/struts2/default.properties
index cacf316da..05e0613b9 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -334,6 +334,9 @@ struts.proxy.cacheFactory=struts
 ### Specifies the ProxyService implementation class.
 struts.proxyService=struts
 
+### Name of the ScopedConstantProvider bean resolving package-scoped constants 
(since 7.5.0)
+struts.scopedConstantProvider=struts
+
 ### Indicates if Dispatcher should handle unexpected exceptions by calling 
sendError()
 ### or simply rethrow it as a ServletException to allow future processing by 
other frameworks like Spring Security
 struts.handle.exception=true
diff --git a/core/src/main/resources/struts-7.5.dtd 
b/core/src/main/resources/struts-7.5.dtd
new file mode 100644
index 000000000..307f5f0e0
--- /dev/null
+++ b/core/src/main/resources/struts-7.5.dtd
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!-- START SNIPPET: strutsDtd -->
+
+<!--
+   Struts configuration DTD.
+   Use the following DOCTYPE
+
+   <!DOCTYPE struts PUBLIC
+    "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+    "https://struts.apache.org/dtds/struts-7.5.dtd";>
+-->
+
+<!ELEMENT struts ((package|include|bean|constant)*,bean-selection?, 
unknown-handler-stack?)>
+<!ATTLIST struts
+    order CDATA #IMPLIED
+>
+
+<!ELEMENT package (scoped-constant*, result-types?, interceptors?, 
default-interceptor-ref?, default-action-ref?, default-class-ref?, 
global-results?, global-allowed-methods?, global-exception-mappings?, action*)>
+<!ATTLIST package
+    name CDATA #REQUIRED
+    extends CDATA #IMPLIED
+    namespace CDATA #IMPLIED
+    abstract CDATA #IMPLIED
+    final CDATA #IMPLIED
+    strict-method-invocation (true|false) "true"
+>
+
+<!ELEMENT scoped-constant EMPTY>
+<!ATTLIST scoped-constant
+    name CDATA #REQUIRED
+    value CDATA #REQUIRED
+>
+
+<!ELEMENT result-types (result-type+)>
+
+<!ELEMENT result-type (param*)>
+<!ATTLIST result-type
+    name CDATA #REQUIRED
+    class CDATA #REQUIRED
+    default (true|false) "false"
+>
+
+<!ELEMENT interceptors (interceptor|interceptor-stack)+>
+
+<!ELEMENT interceptor (param*)>
+<!ATTLIST interceptor
+    name CDATA #REQUIRED
+    class CDATA #REQUIRED
+>
+
+<!ELEMENT interceptor-stack (interceptor-ref*)>
+<!ATTLIST interceptor-stack
+    name CDATA #REQUIRED
+>
+
+<!ELEMENT interceptor-ref (param*)>
+<!ATTLIST interceptor-ref
+    name CDATA #REQUIRED
+>
+
+<!ELEMENT default-interceptor-ref (#PCDATA)>
+<!ATTLIST default-interceptor-ref
+    name CDATA #REQUIRED
+>
+
+<!ELEMENT default-action-ref (#PCDATA)>
+<!ATTLIST default-action-ref
+    name CDATA #REQUIRED
+>
+
+<!ELEMENT default-class-ref (#PCDATA)>
+<!ATTLIST default-class-ref
+    class CDATA #REQUIRED
+>
+
+<!ELEMENT global-results (result+)>
+
+<!ELEMENT global-allowed-methods (#PCDATA)>
+
+<!ELEMENT global-exception-mappings (exception-mapping+)>
+
+<!ELEMENT action 
((param|result|interceptor-ref|exception-mapping)*,allowed-methods?)>
+<!ATTLIST action
+    name CDATA #REQUIRED
+    class CDATA #IMPLIED
+    method CDATA #IMPLIED
+    converter CDATA #IMPLIED
+>
+
+<!ELEMENT param (#PCDATA)>
+<!ATTLIST param
+    name CDATA #REQUIRED
+>
+
+<!ELEMENT result (#PCDATA|param)*>
+<!ATTLIST result
+    name CDATA #IMPLIED
+    type CDATA #IMPLIED
+>
+
+<!ELEMENT exception-mapping (#PCDATA|param)*>
+<!ATTLIST exception-mapping
+    name CDATA #IMPLIED
+    exception CDATA #REQUIRED
+    result CDATA #REQUIRED
+>
+
+<!ELEMENT allowed-methods (#PCDATA)>
+
+<!ELEMENT include (#PCDATA)>
+<!ATTLIST include
+    file CDATA #REQUIRED
+>
+
+<!ELEMENT bean (#PCDATA)>
+<!ATTLIST bean
+    type CDATA #IMPLIED
+    name CDATA #IMPLIED
+    class CDATA #REQUIRED
+    scope CDATA #IMPLIED
+    static CDATA #IMPLIED
+    optional CDATA #IMPLIED
+>
+
+<!ELEMENT bean-selection (#PCDATA)>
+<!ATTLIST bean-selection
+    name CDATA #IMPLIED
+    class CDATA #IMPLIED
+>
+
+<!ELEMENT constant (#PCDATA)>
+<!ATTLIST constant
+    name CDATA #REQUIRED
+    value CDATA #REQUIRED
+>
+
+<!ELEMENT unknown-handler-stack (unknown-handler-ref*)>
+<!ELEMENT unknown-handler-ref (#PCDATA)>
+<!ATTLIST unknown-handler-ref
+    name CDATA #REQUIRED
+>
+
+<!-- END SNIPPET: strutsDtd -->
+
diff --git a/core/src/main/resources/struts-beans.xml 
b/core/src/main/resources/struts-beans.xml
index d12535b9c..abfca2af6 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -258,6 +258,9 @@
     <bean type="org.apache.struts2.interceptor.parameter.ParameterAllowlister" 
name="struts"
           
class="org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister" 
scope="singleton"/>
 
+    <bean type="org.apache.struts2.config.ScopedConstantProvider" name="struts"
+          
class="org.apache.struts2.config.providers.StrutsScopedConstantProvider" 
scope="singleton"/>
+
     <bean type="org.apache.struts2.url.QueryStringBuilder" 
name="strutsQueryStringBuilder"
           class="org.apache.struts2.url.StrutsQueryStringBuilder" 
scope="singleton"/>
     <bean type="org.apache.struts2.url.QueryStringParser" 
name="strutsQueryStringParser"
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java 
b/core/src/test/java/org/apache/struts2/config/CustomScopedConstantProvider.java
similarity index 51%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to 
core/src/test/java/org/apache/struts2/config/CustomScopedConstantProvider.java
index a03ec2d67..f5c1a5f82 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ 
b/core/src/test/java/org/apache/struts2/config/CustomScopedConstantProvider.java
@@ -16,49 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.mock;
+package org.apache.struts2.config;
 
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
-
-import java.util.Set;
-
-/**
- * Mock implementation to be used in unittests
- */
-public class MockContainer implements Container {
-
-    public void inject(Object o) {
-
-    }
-
-    public <T> T inject(Class<T> implementation) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type, String name) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type) {
-        return null;
-    }
-
-    public Set<String> getInstanceNames(Class<?> type) {
-        return null;
-    }
-
-    public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
-    }
-
-    public void removeScopeStrategy() {
-
-    }
+public class CustomScopedConstantProvider implements ScopedConstantProvider {
 
     @Override
-    public void destroy() {
-        // no-op in mock
+    public String getValue(String name) {
+        return "custom";
     }
-
 }
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java 
b/core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java
similarity index 52%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to 
core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java
index a03ec2d67..c09d58755 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java
@@ -16,49 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.mock;
-
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
+package org.apache.struts2.config;
 
 import java.util.Set;
 
-/**
- * Mock implementation to be used in unittests
- */
-public class MockContainer implements Container {
-
-    public void inject(Object o) {
-
-    }
-
-    public <T> T inject(Class<T> implementation) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type, String name) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type) {
-        return null;
-    }
-
-    public Set<String> getInstanceNames(Class<?> type) {
-        return null;
-    }
-
-    public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
-    }
-
-    public void removeScopeStrategy() {
-
-    }
+public class SampleScopableConstants implements ScopableConstants {
 
     @Override
-    public void destroy() {
-        // no-op in mock
+    public Set<String> getNames() {
+        return Set.of("sample.scopable", "sample.unset");
     }
-
 }
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java 
b/core/src/test/java/org/apache/struts2/config/ScopableConstantsTest.java
similarity index 52%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to core/src/test/java/org/apache/struts2/config/ScopableConstantsTest.java
index a03ec2d67..ee1257422 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/test/java/org/apache/struts2/config/ScopableConstantsTest.java
@@ -16,49 +16,31 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.mock;
+package org.apache.struts2.config;
 
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
+import org.apache.struts2.mock.MockContainer;
+import org.junit.Test;
 
 import java.util.Set;
 
-/**
- * Mock implementation to be used in unittests
- */
-public class MockContainer implements Container {
-
-    public void inject(Object o) {
-
-    }
+import static org.assertj.core.api.Assertions.assertThat;
 
-    public <T> T inject(Class<T> implementation) {
-        return null;
-    }
+public class ScopableConstantsTest {
 
-    public <T> T getInstance(Class<T> type, String name) {
-        return null;
-    }
+    @Test
+    public void beanNameWithoutInstanceContributesNoNames() {
+        MockContainer container = new MockContainer() {
+            @Override
+            public Set<String> getInstanceNames(Class<?> type) {
+                return Set.of("unresolvable");
+            }
+        };
 
-    public <T> T getInstance(Class<T> type) {
-        return null;
+        assertThat(ScopableConstants.collectNames(container)).isEmpty();
     }
 
-    public Set<String> getInstanceNames(Class<?> type) {
-        return null;
+    @Test
+    public void containerWithoutScopableBeansContributesNoNames() {
+        assertThat(ScopableConstants.collectNames(new 
MockContainer())).isEmpty();
     }
-
-    public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
-    }
-
-    public void removeScopeStrategy() {
-
-    }
-
-    @Override
-    public void destroy() {
-        // no-op in mock
-    }
-
 }
diff --git 
a/core/src/test/java/org/apache/struts2/config/ScopedConstantProviderWiringTest.java
 
b/core/src/test/java/org/apache/struts2/config/ScopedConstantProviderWiringTest.java
new file mode 100644
index 000000000..cedf54592
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/config/ScopedConstantProviderWiringTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.config;
+
+import org.apache.struts2.StrutsInternalTestCase;
+import org.apache.struts2.config.providers.StrutsScopedConstantProvider;
+
+public class ScopedConstantProviderWiringTest extends StrutsInternalTestCase {
+
+    public void testDispatcherResolvesStrutsScopedConstantProvider() {
+        ScopedConstantProvider provider = 
container.getInstance(ScopedConstantProvider.class);
+
+        assertTrue(provider instanceof StrutsScopedConstantProvider);
+    }
+
+    public void testScopedConstantProviderConstantSelectsCustomBean() {
+        
initDispatcherWithConfigs("struts-default.xml,org/apache/struts2/config/struts-scoped-constants-custom-provider.xml");
+
+        ScopedConstantProvider provider = 
container.getInstance(ScopedConstantProvider.class);
+
+        assertTrue(provider instanceof CustomScopedConstantProvider);
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/config/ScopedConstantsValidationTest.java
 
b/core/src/test/java/org/apache/struts2/config/ScopedConstantsValidationTest.java
new file mode 100644
index 000000000..59dfedc02
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/config/ScopedConstantsValidationTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.config;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.config.providers.StrutsDefaultConfigurationProvider;
+import org.apache.struts2.inject.Container;
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class ScopedConstantsValidationTest {
+
+    private ConfigurationManager configurationManager;
+
+    @After
+    public void tearDown() {
+        if (configurationManager != null) {
+            configurationManager.destroyConfiguration();
+        }
+        ActionContext.clear();
+    }
+
+    @Test
+    public void registeredNameLoads() {
+        Configuration configuration = 
load("struts-scoped-constants-registered.xml");
+
+        
assertThat(configuration.getPackageConfig("registered").getScopedConstants())
+                .isEqualTo(Map.of("sample.scopable", "x"));
+    }
+
+    @Test
+    public void unregisteredNameFails() {
+        assertThatThrownBy(() -> 
load("struts-scoped-constants-unregistered.xml"))
+                .isInstanceOf(ConfigurationException.class)
+                .hasStackTraceContaining("Package [unregistered] declares 
scoped constant [sample.unknown], which is not scopable. "
+                        + "Scopable constants: [sample.scopable, 
sample.unset].");
+    }
+
+    @Test
+    public void abstractPackageIsValidatedToo() {
+        assertThatThrownBy(() -> 
load("struts-scoped-constants-abstract-unregistered.xml"))
+                .isInstanceOf(ConfigurationException.class)
+                .hasStackTraceContaining("Package [abstractBase] declares 
scoped constant [sample.unknown], which is not scopable.");
+    }
+
+    @Test
+    public void anyScopedConstantFailsWhenNothingIsScopable() {
+        assertThatThrownBy(() -> 
load("struts-scoped-constants-no-registry.xml"))
+                .isInstanceOf(ConfigurationException.class)
+                .hasStackTraceContaining("Package [unregistered] declares 
scoped constant [sample.scopable], which is not scopable. "
+                        + "No constants are scopable in this configuration.");
+    }
+
+    @Test
+    public void namesFromAllBeansAreScopable() {
+        Configuration configuration = 
load("struts-scoped-constants-two-beans.xml");
+
+        assertThat(configuration.getPackageConfig("both").getScopedConstants())
+                .isEqualTo(Map.of("sample.scopable", "x", "second.scopable", 
"y"));
+    }
+
+    private Configuration load(String fixture) {
+        configurationManager = new 
ConfigurationManager(Container.DEFAULT_NAME);
+        configurationManager.addContainerProvider(new 
StrutsDefaultConfigurationProvider());
+        configurationManager.addContainerProvider(new 
StrutsXmlConfigurationProvider("org/apache/struts2/config/" + fixture));
+        return configurationManager.getConfiguration();
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/mock/MockContainer.java 
b/core/src/test/java/org/apache/struts2/config/SecondScopableConstants.java
similarity index 52%
copy from core/src/main/java/org/apache/struts2/mock/MockContainer.java
copy to 
core/src/test/java/org/apache/struts2/config/SecondScopableConstants.java
index a03ec2d67..9534dab7f 100644
--- a/core/src/main/java/org/apache/struts2/mock/MockContainer.java
+++ b/core/src/test/java/org/apache/struts2/config/SecondScopableConstants.java
@@ -16,49 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.mock;
-
-import org.apache.struts2.inject.Container;
-import org.apache.struts2.inject.Scope;
+package org.apache.struts2.config;
 
 import java.util.Set;
 
-/**
- * Mock implementation to be used in unittests
- */
-public class MockContainer implements Container {
-
-    public void inject(Object o) {
-
-    }
-
-    public <T> T inject(Class<T> implementation) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type, String name) {
-        return null;
-    }
-
-    public <T> T getInstance(Class<T> type) {
-        return null;
-    }
-
-    public Set<String> getInstanceNames(Class<?> type) {
-        return null;
-    }
-
-    public void setScopeStrategy(Scope.Strategy scopeStrategy) {
-
-    }
-
-    public void removeScopeStrategy() {
-
-    }
+public class SecondScopableConstants implements ScopableConstants {
 
     @Override
-    public void destroy() {
-        // no-op in mock
+    public Set<String> getNames() {
+        return Set.of("second.scopable");
     }
-
 }
diff --git 
a/core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java 
b/core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java
index b0fd702d7..af4e4506d 100644
--- 
a/core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java
+++ 
b/core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java
@@ -20,6 +20,11 @@ package org.apache.struts2.config.entities;
 
 import org.apache.struts2.XWorkTestCase;
 
+import java.util.Map;
+
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
 public class PackageConfigTest extends XWorkTestCase {
 
     public void testFullDefaultInterceptorRef() {
@@ -92,4 +97,85 @@ public class PackageConfigTest extends XWorkTestCase {
         assertFalse(child.isStrictMethodInvocation());
     }
 
+    public void testScopedConstantsChildOverridesParent() {
+        PackageConfig parent = new PackageConfig.Builder("parent")
+                .addScopedConstant("first", "parent-first")
+                .addScopedConstant("second", "parent-second")
+                .build();
+
+        PackageConfig child = new PackageConfig.Builder("child")
+                .addParent(parent)
+                .addScopedConstant("second", "child-second")
+                .build();
+
+        assertEquals(Map.of("second", "child-second"), 
child.getScopedConstants());
+        assertEquals(Map.of("first", "parent-first", "second", 
"child-second"), child.getAllScopedConstants());
+    }
+
+    public void testScopedConstantsInheritedThroughTwoLevels() {
+        PackageConfig grandparent = new PackageConfig.Builder("grandparent")
+                .addScopedConstant("first", "grandparent-first")
+                .build();
+        PackageConfig parent = new PackageConfig.Builder("parent")
+                .addParent(grandparent)
+                .build();
+
+        PackageConfig child = new PackageConfig.Builder("child")
+                .addParent(parent)
+                .build();
+
+        assertEquals(Map.of(), child.getScopedConstants());
+        assertEquals(Map.of("first", "grandparent-first"), 
child.getAllScopedConstants());
+    }
+
+    public void testScopedConstantsFirstAddedParentWins() {
+        PackageConfig first = new PackageConfig.Builder("first")
+                .addScopedConstant("shared", "from-first")
+                .build();
+        PackageConfig second = new PackageConfig.Builder("second")
+                .addScopedConstant("shared", "from-second")
+                .build();
+
+        PackageConfig child = new PackageConfig.Builder("child")
+                .addParent(first)
+                .addParent(second)
+                .build();
+
+        assertEquals("from-first", 
child.getAllScopedConstants().get("shared"));
+    }
+
+    public void testScopedConstantsSurviveBuilderCopy() {
+        PackageConfig parent = new PackageConfig.Builder("parent")
+                .addScopedConstant("first", "parent-first")
+                .build();
+        PackageConfig original = new PackageConfig.Builder("child")
+                .addParent(parent)
+                .addScopedConstant("second", "child-second")
+                .build();
+
+        PackageConfig copy = new PackageConfig.Builder(original).build();
+
+        assertEquals(original.getScopedConstants(), copy.getScopedConstants());
+        assertEquals(original.getAllScopedConstants(), 
copy.getAllScopedConstants());
+    }
+
+    public void testScopedConstantsAreUnmodifiableAfterBuild() {
+        PackageConfig config = new PackageConfig.Builder("pkg")
+                .addScopedConstant("first", "value")
+                .build();
+
+        Map<String, String> own = config.getScopedConstants();
+        Map<String, String> all = config.getAllScopedConstants();
+
+        assertThrows(UnsupportedOperationException.class, () -> own.put("x", 
"y"));
+        assertThrows(UnsupportedOperationException.class, () -> all.put("x", 
"y"));
+    }
+
+    public void testScopedConstantsTakePartInEquality() {
+        PackageConfig plain = new PackageConfig.Builder("pkg").build();
+        PackageConfig scoped = new 
PackageConfig.Builder("pkg").addScopedConstant("first", "value").build();
+
+        assertNotEquals(plain, scoped);
+    }
+
 }
diff --git 
a/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
 
b/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
new file mode 100644
index 000000000..c00ded1c5
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.config.providers;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.XWorkTestCase;
+import org.apache.struts2.config.ScopedConstantProvider;
+import org.apache.struts2.config.StrutsXmlConfigurationProvider;
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.mock.MockActionInvocation;
+import org.apache.struts2.mock.MockActionProxy;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class StrutsScopedConstantProviderTest extends XWorkTestCase {
+
+    private ScopedConstantProvider provider;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider("org/apache/struts2/config/struts-scoped-constants-provider.xml"));
+        provider = container.getInstance(ScopedConstantProvider.class);
+    }
+
+    public void testDefaultProviderIsStrutsImplementation() {
+        assertTrue(provider instanceof StrutsScopedConstantProvider);
+    }
+
+    public void testPackageValueWinsInsideInvocation() {
+        enterPackage("override");
+
+        assertEquals("override", provider.getValue("sample.scopable"));
+    }
+
+    public void testInheritedValueResolvesForChildPackage() {
+        enterPackage("child");
+
+        assertEquals("base", provider.getValue("sample.scopable"));
+    }
+
+    public void testEmptyOverrideIsReturnedAsIs() {
+        enterPackage("empty");
+
+        assertEquals("", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutOverride() {
+        enterPackage("plain");
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutInvocation() {
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutActionContext() {
+        ActionContext context = ActionContext.getContext();
+        ActionContext.clear();
+        try {
+            assertEquals("global", provider.getValue("sample.scopable"));
+        } finally {
+            context.bind();
+        }
+    }
+
+    public void testGlobalValueWhenInvocationHasNoProxy() {
+        ActionContext.getContext().withActionInvocation(new 
MockActionInvocation());
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWhenProxyHasNoConfig() {
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setProxy(new MockActionProxy());
+        ActionContext.getContext().withActionInvocation(invocation);
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWhenPackageIsUnknown() {
+        enterPackage("missing");
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testUnsetConstantWithoutOverrideIsNull() {
+        enterPackage("plain");
+
+        assertNull(provider.getValue("sample.unset"));
+    }
+
+    public void testUnregisteredNameThrows() {
+        assertThatThrownBy(() -> provider.getValue("sample.unknown"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[sample.unknown]");
+    }
+
+    public void testGlobalConstantUntouchedByScopedRead() {
+        enterPackage("override");
+        provider.getValue("sample.scopable");
+
+        assertEquals("global", container.getInstance(String.class, 
"sample.scopable"));
+    }
+
+    private void enterPackage(String packageName) {
+        MockActionProxy proxy = new MockActionProxy();
+        proxy.setConfig(new ActionConfig.Builder(packageName, "test", 
ActionSupport.class.getName()).build());
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setProxy(proxy);
+        ActionContext.getContext().withActionInvocation(invocation);
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java
 
b/core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java
index 0c4bd9714..35e2cdb2f 100644
--- 
a/core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java
+++ 
b/core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java
@@ -24,7 +24,9 @@ import org.apache.struts2.config.RuntimeConfiguration;
 import org.apache.struts2.config.entities.PackageConfig;
 
 import java.util.List;
+import java.util.Map;
 
+import static org.assertj.core.api.Assertions.assertThat;
 
 /**
  * Created by IntelliJ IDEA.
@@ -198,6 +200,35 @@ public class XmlConfigurationProviderPackagesTest extends 
ConfigurationTestBase
         assertEquals(expectedNoDefaultClassRefPackage, 
configuration.getPackageConfig(noDefaultClassRefPkgName));
     }
 
+    public void testScopedConstantsLoadAndInherit() throws 
ConfigurationException {
+        
buildConfigurationProvider(getXmlConfigFilePath("struts-scoped-constants-inheritance.xml"));
+
+        PackageConfig parent = configuration.getPackageConfig("parent");
+        PackageConfig child = configuration.getPackageConfig("child");
+
+        assertEquals(Map.of("first", "parent-first", "second", 
"parent-second"), parent.getScopedConstants());
+        assertEquals(Map.of("second", "child-second"), 
child.getScopedConstants());
+        assertEquals(Map.of("first", "parent-first", "second", 
"child-second"), child.getAllScopedConstants());
+    }
+
+    public void testDuplicateScopedConstantInOnePackageFails() {
+        try {
+            
buildConfigurationProvider(getXmlConfigFilePath("struts-scoped-constants-duplicate.xml"));
+            fail("Should have thrown a ConfigurationException");
+        } catch (ConfigurationException e) {
+            assertThat(e).hasStackTraceContaining("Package [dup] declares 
scoped constant [first] more than once");
+        }
+    }
+
+    public void testScopedConstantIsRejectedUnderOlderDtd() {
+        try {
+            
buildConfigurationProvider(getXmlConfigFilePath("struts-scoped-constants-old-dtd.xml"));
+            fail("Should have thrown a ConfigurationException");
+        } catch (ConfigurationException e) {
+            assertThat(e).hasStackTraceContaining("\"scoped-constant\" must be 
declared");
+        }
+    }
+
     private String getXmlConfigFilePath(String fileName) {
         return "org/apache/struts2/config/providers/" + fileName;
     }
diff --git 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-duplicate.xml
 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-duplicate.xml
new file mode 100644
index 000000000..92b9ec116
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-duplicate.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <package name="dup" namespace="/dup">
+        <scoped-constant name="first" value="one"/>
+        <scoped-constant name="first" value="two"/>
+    </package>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-inheritance.xml
 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-inheritance.xml
new file mode 100644
index 000000000..ef0797de8
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-inheritance.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <package name="parent" namespace="/parent">
+        <scoped-constant name="first" value="parent-first"/>
+        <scoped-constant name="second" value="parent-second"/>
+    </package>
+
+    <package name="child" namespace="/child" extends="parent">
+        <scoped-constant name="second" value="child-second"/>
+    </package>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-old-dtd.xml
 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-old-dtd.xml
new file mode 100644
index 000000000..35817e260
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-scoped-constants-old-dtd.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 6.5//EN"
+        "struts-6.5.dtd">
+<struts>
+    <package name="old" namespace="/old">
+        <scoped-constant name="first" value="one"/>
+    </package>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-abstract-unregistered.xml
 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-abstract-unregistered.xml
new file mode 100644
index 000000000..6eb2c623c
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-abstract-unregistered.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <package name="abstractBase" abstract="true">
+        <scoped-constant name="sample.unknown" value="x"/>
+    </package>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-custom-provider.xml
 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-custom-provider.xml
new file mode 100644
index 000000000..df4dfcbea
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-custom-provider.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <bean type="org.apache.struts2.config.ScopedConstantProvider" name="custom"
+          class="org.apache.struts2.config.CustomScopedConstantProvider"/>
+
+    <constant name="struts.scopedConstantProvider" value="custom"/>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-no-registry.xml
 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-no-registry.xml
new file mode 100644
index 000000000..6d622d8ac
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-no-registry.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <package name="unregistered" namespace="/unregistered">
+        <scoped-constant name="sample.scopable" value="x"/>
+    </package>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-provider.xml
 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-provider.xml
new file mode 100644
index 000000000..84a163ad6
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-provider.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <constant name="sample.scopable" value="global"/>
+
+    <package name="base" abstract="true">
+        <scoped-constant name="sample.scopable" value="base"/>
+    </package>
+
+    <package name="child" namespace="/child" extends="base"/>
+
+    <package name="override" namespace="/override" extends="base">
+        <scoped-constant name="sample.scopable" value="override"/>
+    </package>
+
+    <package name="empty" namespace="/empty" extends="base">
+        <scoped-constant name="sample.scopable" value=""/>
+    </package>
+
+    <package name="plain" namespace="/plain"/>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-registered.xml
 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-registered.xml
new file mode 100644
index 000000000..f3e5a5401
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-registered.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <package name="registered" namespace="/registered">
+        <scoped-constant name="sample.scopable" value="x"/>
+    </package>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-two-beans.xml
 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-two-beans.xml
new file mode 100644
index 000000000..d79022cf6
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-two-beans.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="second"
+          class="org.apache.struts2.config.SecondScopableConstants"/>
+
+    <package name="both" namespace="/both">
+        <scoped-constant name="sample.scopable" value="x"/>
+        <scoped-constant name="second.scopable" value="y"/>
+    </package>
+</struts>
diff --git 
a/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-unregistered.xml
 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-unregistered.xml
new file mode 100644
index 000000000..0fd512212
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/struts-scoped-constants-unregistered.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <package name="unregistered" namespace="/unregistered">
+        <scoped-constant name="sample.unknown" value="x"/>
+    </package>
+</struts>
diff --git a/docs/superpowers/plans/2026-10-10-WW-3476-scoped-constants.md 
b/docs/superpowers/plans/2026-10-10-WW-3476-scoped-constants.md
new file mode 100644
index 000000000..67d798f11
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-10-WW-3476-scoped-constants.md
@@ -0,0 +1,1074 @@
+# WW-3476 Package-Scoped Constants Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let a `<package>` override selected Struts constants with 
`<scoped-constant name value/>`, inherited through `extends`, read at request 
time through a `ScopedConstantProvider` that falls back to the global constant.
+
+**Architecture:** `PackageConfig` stores each package's own overrides and, at 
`build()`, an eagerly merged view (parents first, own entries win). Components 
opt a constant in by registering a `ScopableConstants` bean; 
`DefaultConfiguration` rejects any `<scoped-constant>` whose name no bean 
registered. `StrutsScopedConstantProvider.getValue(name)` reads the current 
`ActionInvocation`'s package and falls back to 
`container.getInstance(String.class, name)` — the singleton's injected value  
[...]
+
+**Tech Stack:** Java 17, Struts core config/DI (`org.apache.struts2.inject`), 
XML DTD, JUnit 3 (`XWorkTestCase`) and JUnit 4, AssertJ 3.27.
+
+**Spec:** 
`docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md`
+
+## Global Constraints
+
+- Branch: `WW-3476-scoped-constants` (already exists, holds the spec commit). 
Never push to `main`.
+- Commit messages start with `WW-3476 ` and end with `Co-Authored-By: Claude 
Opus 5.5 <[email protected]>`.
+- Stage explicit paths only — never `git add -A` / `git add .`; check `git 
diff --cached --name-only` before every commit.
+- Never `git stash` in this repo.
+- Tests are JUnit 3 or 4 — never Jupiter. In a class extending 
`XWorkTestCase`, methods are `public void testXxx()` with no `@Test`.
+- Maven: the Bash tool has no `mvn`; use `./mvnw` from the repo root, log to 
`core/target/ww3476.log`, and read Maven's exit code — never trust a grep over 
the output alone.
+- Every new file starts with the ASF license header, copied verbatim from 
`core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java` 
(Java) or 
`core/src/test/resources/org/apache/struts2/config/providers/xwork-test-package-final.xml`
 (XML).
+- New public API carries `@since 7.5.0`. Comments: default to none; short 
Javadoc on new public types/methods only.
+- Purely additive: do not edit `struts-6.5.dtd` or any older DTD; no existing 
constructor/interface signature changes.
+- No constant is registered as scopable in `src/main` — `ScopableConstants` 
beans exist only under `src/test`.
+- Default implementation name prefix is `Struts*`.
+- Watch-it-fail mutations are applied with the Edit tool and confirmed with 
`git diff --stat` (BSD `sed -i` silently no-ops here), then reverted with Edit.
+
+## Review Focus
+
+1. **An abstract package declares an unregistered scoped constant** — must 
still fail at startup, even though `buildRuntimeConfiguration` skips abstract 
packages when collecting actions. Test: Task 3 `abstractPackageIsValidatedToo`.
+2. **`<scoped-constant value=""/>`** — an empty override is a real value; the 
provider must return `""`, not fall back to the global constant. Test: Task 4 
`testEmptyOverrideIsReturnedAsIs`.
+3. **Invocation without a resolvable package** (proxy null, `ActionConfig` 
null, package name not in `Configuration`) — must return the global value, 
never throw. Tests: Task 4 `testGlobalValueWhenInvocationHasNoProxy`, 
`testGlobalValueWhenProxyHasNoConfig`, `testGlobalValueWhenPackageIsUnknown`.
+4. **Scopable constant with no global value** — outside an override the 
provider returns `null` (as `container.getInstance` does), it must not throw. 
Test: Task 4 `testUnsetConstantWithoutOverrideIsNull`.
+5. **Several `ScopableConstants` beans** — the scopable set is the union; a 
name from the second bean must validate. Test: Task 3 
`namesFromAllBeansAreScopable`.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java` 
(modify) | Store own + merged scoped constants |
+| `core/src/main/resources/struts-7.5.dtd` (create) | DTD with 
`<scoped-constant>` |
+| 
`core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java`
 (modify) | Map the 7.5 public id to the DTD |
+| 
`core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java`
 (modify) | Parse `<scoped-constant>` |
+| `core/src/main/java/org/apache/struts2/config/ScopableConstants.java` 
(create) | Registry contract + union helper |
+| 
`core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java` 
(modify) | Startup validation |
+| `core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java` 
(create) | Lookup contract |
+| 
`core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java`
 (create) | Default lookup |
+| `core/src/main/java/org/apache/struts2/StrutsConstants.java` (modify) | 
Bean-selection constant |
+| 
`core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` 
(modify) | Alias the default bean |
+| `core/src/main/resources/struts-beans.xml` (modify) | Runtime binding |
+| 
`core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java`
 (modify) | Test-harness binding |
+| `core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java`, 
`SecondScopableConstants.java` (create) | Test-only registry beans |
+| Tests + fixtures listed per task | |
+
+---
+
+### Task 1: `PackageConfig` stores and merges scoped constants
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java`
+- Test: 
`core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java` 
(JUnit 3, extends `XWorkTestCase`)
+
+**Interfaces:**
+- Produces: `PackageConfig.Builder addScopedConstant(String name, String 
value)`; `Map<String, String> PackageConfig.getScopedConstants()` (own only, 
unmodifiable after `build()`); `Map<String, String> 
PackageConfig.getAllScopedConstants()` (merged, unmodifiable, empty before 
`build()`).
+
+- [ ] **Step 1: Write the failing tests** — append to `PackageConfigTest` (add 
`import java.util.Map;`):
+
+```java
+    public void testScopedConstantsChildOverridesParent() {
+        PackageConfig parent = new PackageConfig.Builder("parent")
+                .addScopedConstant("first", "parent-first")
+                .addScopedConstant("second", "parent-second")
+                .build();
+
+        PackageConfig child = new PackageConfig.Builder("child")
+                .addParent(parent)
+                .addScopedConstant("second", "child-second")
+                .build();
+
+        assertEquals(Map.of("second", "child-second"), 
child.getScopedConstants());
+        assertEquals(Map.of("first", "parent-first", "second", 
"child-second"), child.getAllScopedConstants());
+    }
+
+    public void testScopedConstantsInheritedThroughTwoLevels() {
+        PackageConfig grandparent = new PackageConfig.Builder("grandparent")
+                .addScopedConstant("first", "grandparent-first")
+                .build();
+        PackageConfig parent = new PackageConfig.Builder("parent")
+                .addParent(grandparent)
+                .build();
+
+        PackageConfig child = new PackageConfig.Builder("child")
+                .addParent(parent)
+                .build();
+
+        assertEquals(Map.of(), child.getScopedConstants());
+        assertEquals(Map.of("first", "grandparent-first"), 
child.getAllScopedConstants());
+    }
+
+    public void testScopedConstantsFirstAddedParentWins() {
+        PackageConfig first = new PackageConfig.Builder("first")
+                .addScopedConstant("shared", "from-first")
+                .build();
+        PackageConfig second = new PackageConfig.Builder("second")
+                .addScopedConstant("shared", "from-second")
+                .build();
+
+        PackageConfig child = new PackageConfig.Builder("child")
+                .addParent(first)
+                .addParent(second)
+                .build();
+
+        assertEquals("from-first", 
child.getAllScopedConstants().get("shared"));
+    }
+
+    public void testScopedConstantsSurviveBuilderCopy() {
+        PackageConfig parent = new PackageConfig.Builder("parent")
+                .addScopedConstant("first", "parent-first")
+                .build();
+        PackageConfig original = new PackageConfig.Builder("child")
+                .addParent(parent)
+                .addScopedConstant("second", "child-second")
+                .build();
+
+        PackageConfig copy = new PackageConfig.Builder(original).build();
+
+        assertEquals(original.getScopedConstants(), copy.getScopedConstants());
+        assertEquals(original.getAllScopedConstants(), 
copy.getAllScopedConstants());
+    }
+
+    public void testScopedConstantsAreUnmodifiableAfterBuild() {
+        PackageConfig config = new PackageConfig.Builder("pkg")
+                .addScopedConstant("first", "value")
+                .build();
+
+        assertThrows(UnsupportedOperationException.class, () -> 
config.getScopedConstants().put("x", "y"));
+        assertThrows(UnsupportedOperationException.class, () -> 
config.getAllScopedConstants().put("x", "y"));
+    }
+
+    public void testScopedConstantsTakePartInEquality() {
+        PackageConfig plain = new PackageConfig.Builder("pkg").build();
+        PackageConfig scoped = new 
PackageConfig.Builder("pkg").addScopedConstant("first", "value").build();
+
+        assertFalse(plain.equals(scoped));
+    }
+```
+
+Also add `import static org.junit.Assert.assertThrows;` — JUnit 4.13 provides 
it, and it works inside this JUnit 3 class.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `./mvnw test -DskipAssembly -pl core -Dtest=PackageConfigTest 
-Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo 
"exit=$?"; grep -E "ERROR|Tests run|cannot find symbol" core/target/ww3476.log 
| head -20`
+Expected: `exit=1`, compilation error `cannot find symbol ... 
addScopedConstant`.
+
+- [ ] **Step 3: Implement** in `PackageConfig.java`.
+
+Fields, after `protected boolean strictMethodInvocation = true;`:
+
+```java
+    protected Map<String, String> scopedConstants;
+    protected Map<String, String> allScopedConstants;
+```
+
+In `PackageConfig(String name)`, after `parents = new ArrayList<>();`:
+
+```java
+        scopedConstants = new LinkedHashMap<>();
+        allScopedConstants = Collections.emptyMap();
+```
+
+In `PackageConfig(PackageConfig orig)`, after `this.strictMethodInvocation = 
orig.strictMethodInvocation;`:
+
+```java
+        this.scopedConstants = new LinkedHashMap<>(orig.scopedConstants);
+        this.allScopedConstants = orig.allScopedConstants;
+```
+
+Getters, directly after `getAllResultTypeConfigs()`:
+
+```java
+    /**
+     * @return scoped constants declared by this package itself, without 
inherited ones
+     * @since 7.5.0
+     */
+    public Map<String, String> getScopedConstants() {
+        return scopedConstants;
+    }
+
+    /**
+     * @return scoped constants in effect for this package: inherited ones, 
overridden by its own
+     * @since 7.5.0
+     */
+    public Map<String, String> getAllScopedConstants() {
+        return allScopedConstants;
+    }
+```
+
+In `equals`, after the `parents` comparison line:
+
+```java
+        if (!Objects.equals(scopedConstants, that.scopedConstants)) return 
false;
+```
+
+In `hashCode`, after the `parents` line:
+
+```java
+        result = 31 * result + (scopedConstants != null ? 
scopedConstants.hashCode() : 0);
+```
+
+In `Builder`, after `addResultTypeConfig(...)`:
+
+```java
+        /**
+         * @since 7.5.0
+         */
+        public Builder addScopedConstant(String name, String value) {
+            target.scopedConstants.put(name, value);
+            return this;
+        }
+```
+
+In `Builder.build()`, before `PackageConfig result = target;`:
+
+```java
+            target.scopedConstants = 
Collections.unmodifiableMap(target.scopedConstants);
+            target.allScopedConstants = mergeScopedConstants(target);
+```
+
+And in `Builder`, after `build()`:
+
+```java
+        private static Map<String, String> mergeScopedConstants(PackageConfig 
config) {
+            Map<String, String> merged = new LinkedHashMap<>();
+            for (PackageConfig parent : config.parents) {
+                merged.putAll(parent.getAllScopedConstants());
+            }
+            merged.putAll(config.scopedConstants);
+            return Collections.unmodifiableMap(merged);
+        }
+```
+
+(`addParent` inserts at index 0, so iterating `parents` and `putAll`-ing lets 
the first-added parent win — the same order as `getAllResultTypeConfigs()`; 
`testScopedConstantsFirstAddedParentWins` pins it.)
+
+- [ ] **Step 4: Run to verify they pass** — same command as Step 2. Expected: 
`exit=0`, `Tests run: N, Failures: 0, Errors: 0`.
+
+- [ ] **Step 5: Watch it fail** — with Edit, swap the merge order in 
`mergeScopedConstants` (move `merged.putAll(config.scopedConstants);` above the 
`for` loop). `git diff --stat` must show `PackageConfig.java` changed. Re-run 
Step 2's command: expect `testScopedConstantsChildOverridesParent` to FAIL. 
Revert with Edit; re-run; expect `exit=0`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java 
core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java
+git diff --cached --name-only
+git commit -m "WW-3476 feat(config): store scoped constants on PackageConfig
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 2: `struts-7.5.dtd` and parsing `<scoped-constant>`
+
+**Files:**
+- Create: `core/src/main/resources/struts-7.5.dtd`
+- Modify: 
`core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java:47-54`
+- Modify: 
`core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java`
 (`addPackage`, ~line 411)
+- Create fixtures in 
`core/src/test/resources/org/apache/struts2/config/providers/`: 
`xwork-test-scoped-constants.xml`, `xwork-test-scoped-constants-duplicate.xml`, 
`xwork-test-scoped-constants-old-dtd.xml`
+- Test: 
`core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java`
 (JUnit 3, extends `ConfigurationTestBase`)
+
+**Interfaces:**
+- Consumes: `PackageConfig.Builder.addScopedConstant`, `getScopedConstants`, 
`getAllScopedConstants` (Task 1).
+- Produces: public id `-//Apache Software Foundation//DTD Struts Configuration 
7.5//EN` → `struts-7.5.dtd`; `protected void 
XmlDocConfigurationProvider.loadScopedConstants(PackageConfig.Builder, 
Element)`.
+
+- [ ] **Step 1: Create the fixtures** (license header first in each, per 
Global Constraints).
+
+`xwork-test-scoped-constants.xml`:
+
+```xml
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <package name="parent" namespace="/parent">
+        <scoped-constant name="first" value="parent-first"/>
+        <scoped-constant name="second" value="parent-second"/>
+    </package>
+
+    <package name="child" namespace="/child" extends="parent">
+        <scoped-constant name="second" value="child-second"/>
+    </package>
+</struts>
+```
+
+`xwork-test-scoped-constants-duplicate.xml`:
+
+```xml
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+<struts>
+    <package name="dup" namespace="/dup">
+        <scoped-constant name="first" value="one"/>
+        <scoped-constant name="first" value="two"/>
+    </package>
+</struts>
+```
+
+`xwork-test-scoped-constants-old-dtd.xml`:
+
+```xml
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 6.5//EN"
+        "struts-6.5.dtd">
+<struts>
+    <package name="old" namespace="/old">
+        <scoped-constant name="first" value="one"/>
+    </package>
+</struts>
+```
+
+- [ ] **Step 2: Write the failing tests** — append to 
`XmlConfigurationProviderPackagesTest` (add `import java.util.Map;` and `import 
static org.assertj.core.api.Assertions.assertThat;`):
+
+```java
+    public void testScopedConstantsLoadAndInherit() throws 
ConfigurationException {
+        
buildConfigurationProvider(getXmlConfigFilePath("xwork-test-scoped-constants.xml"));
+
+        PackageConfig parent = configuration.getPackageConfig("parent");
+        PackageConfig child = configuration.getPackageConfig("child");
+
+        assertEquals(Map.of("first", "parent-first", "second", 
"parent-second"), parent.getScopedConstants());
+        assertEquals(Map.of("second", "child-second"), 
child.getScopedConstants());
+        assertEquals(Map.of("first", "parent-first", "second", 
"child-second"), child.getAllScopedConstants());
+    }
+
+    public void testDuplicateScopedConstantInOnePackageFails() {
+        try {
+            
buildConfigurationProvider(getXmlConfigFilePath("xwork-test-scoped-constants-duplicate.xml"));
+            fail("Should have thrown a ConfigurationException");
+        } catch (ConfigurationException e) {
+            assertThat(e).hasStackTraceContaining("Package [dup] declares 
scoped constant [first] more than once");
+        }
+    }
+
+    public void testScopedConstantIsRejectedUnderOlderDtd() {
+        try {
+            
buildConfigurationProvider(getXmlConfigFilePath("xwork-test-scoped-constants-old-dtd.xml"));
+            fail("Should have thrown a ConfigurationException");
+        } catch (ConfigurationException e) {
+            assertThat(e).hasStackTraceContaining("\"scoped-constant\" must be 
declared");
+        }
+    }
+```
+
+(The old-DTD assertion matches the parser's `Element type "scoped-constant" 
must be declared.` — not the fixture file name, which also contains 
`scoped-constant`.)
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `./mvnw test -DskipAssembly -pl core 
-Dtest=XmlConfigurationProviderPackagesTest 
-Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo 
"exit=$?"; grep -E "Tests run|FAIL|testScoped|testDuplicate" 
core/target/ww3476.log | head -20`
+Expected: `exit=1`; `testScopedConstantsLoadAndInherit` and 
`testDuplicateScopedConstantInOnePackageFails` fail/error (7.5 public id is 
unmapped). `testScopedConstantIsRejectedUnderOlderDtd` already passes — that is 
fine, it guards that the 6.5 DTD stays unchanged.
+
+- [ ] **Step 4: Create the DTD**
+
+```bash
+cp core/src/main/resources/struts-6.5.dtd 
core/src/main/resources/struts-7.5.dtd
+```
+
+Then, with Edit, in `struts-7.5.dtd`:
+- In the header comment, replace `"-//Apache Software Foundation//DTD Struts 
Configuration 6.5//EN"` with `"-//Apache Software Foundation//DTD Struts 
Configuration 7.5//EN"` and `"https://struts.apache.org/dtds/struts-6.5.dtd"` 
with `"https://struts.apache.org/dtds/struts-7.5.dtd"`.
+- Replace
+
+```
+<!ELEMENT package (result-types?, interceptors?, default-interceptor-ref?, 
default-action-ref?, default-class-ref?, global-results?, 
global-allowed-methods?, global-exception-mappings?, action*)>
+```
+
+with
+
+```
+<!ELEMENT package (scoped-constant*, result-types?, interceptors?, 
default-interceptor-ref?, default-action-ref?, default-class-ref?, 
global-results?, global-allowed-methods?, global-exception-mappings?, action*)>
+```
+
+- Directly after the `<!ATTLIST package ... >` block, add:
+
+```
+<!ELEMENT scoped-constant EMPTY>
+<!ATTLIST scoped-constant
+    name CDATA #REQUIRED
+    value CDATA #REQUIRED
+>
+```
+
+Check: `diff core/src/main/resources/struts-6.5.dtd 
core/src/main/resources/struts-7.5.dtd` shows only those changes.
+
+- [ ] **Step 5: Register the DTD** — in 
`StrutsXmlConfigurationProvider.STRUTS_DTD_MAPPINGS`, change the last entry to:
+
+```java
+            "-//Apache Software Foundation//DTD Struts Configuration 6.5//EN", 
"struts-6.5.dtd",
+            "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN", 
"struts-7.5.dtd");
+```
+
+- [ ] **Step 6: Parse the element** — in 
`XmlDocConfigurationProvider.addPackage`, directly after `LOG.debug("Loaded 
{}", newPackage);`:
+
+```java
+        loadScopedConstants(newPackage, packageElement);
+```
+
+And add, directly before `loadGlobalResults(...)`'s Javadoc:
+
+```java
+    /**
+     * @since 7.5.0
+     */
+    protected void loadScopedConstants(PackageConfig.Builder packageContext, 
Element packageElement) {
+        Set<String> declared = new HashSet<>();
+        iterateChildrenByTagName(packageElement, "scoped-constant", 
constantElement -> {
+            String name = constantElement.getAttribute("name");
+            if (!declared.add(name)) {
+                throw new ConfigurationException(format("Package [%s] declares 
scoped constant [%s] more than once",
+                        packageContext.getName(), name), constantElement);
+            }
+            packageContext.addScopedConstant(name, 
constantElement.getAttribute("value"));
+        });
+    }
+```
+
+(The static `format` import, `Set` and `HashSet` are already present in this 
file.)
+
+- [ ] **Step 7: Run to verify they pass** — Step 3's command. Expected: 
`exit=0`, all tests in the class pass (including the pre-existing 6.5/2.x 
fixtures).
+
+- [ ] **Step 8: Watch it fail** — with Edit, change `if (!declared.add(name)) 
{` to `if (false && !declared.add(name)) {`; `git diff --stat` shows the file; 
re-run: `testDuplicateScopedConstantInOnePackageFails` FAILS. Revert. Then, 
with Edit, add `scoped-constant*, ` to the `package` content model in 
**`struts-6.5.dtd`** plus the same `<!ELEMENT scoped-constant ...>` block; 
re-run: `testScopedConstantIsRejectedUnderOlderDtd` FAILS. Revert with Edit; 
`git diff --stat core/src/main/resour [...]
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add core/src/main/resources/struts-7.5.dtd \
+  
core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java
 \
+  
core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java
 \
+  
core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java
 \
+  
core/src/test/resources/org/apache/struts2/config/providers/xwork-test-scoped-constants.xml
 \
+  
core/src/test/resources/org/apache/struts2/config/providers/xwork-test-scoped-constants-duplicate.xml
 \
+  
core/src/test/resources/org/apache/struts2/config/providers/xwork-test-scoped-constants-old-dtd.xml
+git diff --cached --name-only
+git commit -m "WW-3476 feat(config): add struts-7.5.dtd with <scoped-constant>
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 3: `ScopableConstants` registry and startup validation
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/config/ScopableConstants.java`
+- Modify: 
`core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java` 
(`buildRuntimeConfiguration`, ~line 451)
+- Create (test): 
`core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java`, 
`core/src/test/java/org/apache/struts2/config/SecondScopableConstants.java`
+- Create fixtures in `core/src/test/resources/org/apache/struts2/config/`: 
`scoped-constants-registered.xml`, `scoped-constants-unregistered.xml`, 
`scoped-constants-abstract-unregistered.xml`, 
`scoped-constants-no-registry.xml`, `scoped-constants-two-beans.xml`
+- Test: 
`core/src/test/java/org/apache/struts2/config/ScopedConstantsValidationTest.java`
 (new, JUnit 4)
+
+**Interfaces:**
+- Consumes: `PackageConfig.getScopedConstants()` (Task 1); `struts-7.5.dtd` 
mapping (Task 2).
+- Produces: `interface ScopableConstants { Set<String> getNames(); static 
Set<String> collectNames(Container container); }`; test beans 
`SampleScopableConstants` → `{"sample.scopable", "sample.unset"}`, 
`SecondScopableConstants` → `{"second.scopable"}`.
+
+- [ ] **Step 1: Create the registry contract** — `ScopableConstants.java`:
+
+```java
+package org.apache.struts2.config;
+
+import org.apache.struts2.inject.Container;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Declares constants a package may override with {@code <scoped-constant>}. 
Register implementations as named beans;
+ * the scopable set is the union over all of them.
+ *
+ * @since 7.5.0
+ */
+public interface ScopableConstants {
+
+    Set<String> getNames();
+
+    static Set<String> collectNames(Container container) {
+        Set<String> names = new HashSet<>();
+        for (String beanName : 
container.getInstanceNames(ScopableConstants.class)) {
+            names.addAll(container.getInstance(ScopableConstants.class, 
beanName).getNames());
+        }
+        return Collections.unmodifiableSet(names);
+    }
+}
+```
+
+- [ ] **Step 2: Create the test beans**
+
+`SampleScopableConstants.java`:
+
+```java
+package org.apache.struts2.config;
+
+import java.util.Set;
+
+public class SampleScopableConstants implements ScopableConstants {
+
+    @Override
+    public Set<String> getNames() {
+        return Set.of("sample.scopable", "sample.unset");
+    }
+}
+```
+
+`SecondScopableConstants.java`:
+
+```java
+package org.apache.struts2.config;
+
+import java.util.Set;
+
+public class SecondScopableConstants implements ScopableConstants {
+
+    @Override
+    public Set<String> getNames() {
+        return Set.of("second.scopable");
+    }
+}
+```
+
+- [ ] **Step 3: Create the fixtures.** Each file is: the XML license header, 
then this DOCTYPE, then the `<struts>` body shown below.
+
+```xml
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+```
+
+`scoped-constants-registered.xml`:
+
+```xml
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <package name="registered" namespace="/registered">
+        <scoped-constant name="sample.scopable" value="x"/>
+    </package>
+</struts>
+```
+
+`scoped-constants-unregistered.xml`:
+
+```xml
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <package name="unregistered" namespace="/unregistered">
+        <scoped-constant name="sample.unknown" value="x"/>
+    </package>
+</struts>
+```
+
+`scoped-constants-abstract-unregistered.xml`:
+
+```xml
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <package name="abstractBase" abstract="true">
+        <scoped-constant name="sample.unknown" value="x"/>
+    </package>
+</struts>
+```
+
+`scoped-constants-no-registry.xml`:
+
+```xml
+<struts>
+    <package name="unregistered" namespace="/unregistered">
+        <scoped-constant name="sample.scopable" value="x"/>
+    </package>
+</struts>
+```
+
+`scoped-constants-two-beans.xml`:
+
+```xml
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="second"
+          class="org.apache.struts2.config.SecondScopableConstants"/>
+
+    <package name="both" namespace="/both">
+        <scoped-constant name="sample.scopable" value="x"/>
+        <scoped-constant name="second.scopable" value="y"/>
+    </package>
+</struts>
+```
+
+- [ ] **Step 4: Write the failing tests** — 
`ScopedConstantsValidationTest.java`:
+
+```java
+package org.apache.struts2.config;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.config.providers.StrutsDefaultConfigurationProvider;
+import org.apache.struts2.inject.Container;
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class ScopedConstantsValidationTest {
+
+    private ConfigurationManager configurationManager;
+
+    @After
+    public void tearDown() {
+        if (configurationManager != null) {
+            configurationManager.destroyConfiguration();
+        }
+        ActionContext.clear();
+    }
+
+    @Test
+    public void registeredNameLoads() {
+        Configuration configuration = load("scoped-constants-registered.xml");
+
+        
assertThat(configuration.getPackageConfig("registered").getScopedConstants())
+                .isEqualTo(Map.of("sample.scopable", "x"));
+    }
+
+    @Test
+    public void unregisteredNameFails() {
+        assertThatThrownBy(() -> load("scoped-constants-unregistered.xml"))
+                .isInstanceOf(ConfigurationException.class)
+                .hasStackTraceContaining("Package [unregistered] declares 
scoped constant [sample.unknown], which is not scopable. "
+                        + "Scopable constants: [sample.scopable, 
sample.unset].");
+    }
+
+    @Test
+    public void abstractPackageIsValidatedToo() {
+        assertThatThrownBy(() -> 
load("scoped-constants-abstract-unregistered.xml"))
+                .isInstanceOf(ConfigurationException.class)
+                .hasStackTraceContaining("Package [abstractBase] declares 
scoped constant [sample.unknown], which is not scopable.");
+    }
+
+    @Test
+    public void anyScopedConstantFailsWhenNothingIsScopable() {
+        assertThatThrownBy(() -> load("scoped-constants-no-registry.xml"))
+                .isInstanceOf(ConfigurationException.class)
+                .hasStackTraceContaining("Package [unregistered] declares 
scoped constant [sample.scopable], which is not scopable. "
+                        + "No constants are scopable in this configuration.");
+    }
+
+    @Test
+    public void namesFromAllBeansAreScopable() {
+        Configuration configuration = load("scoped-constants-two-beans.xml");
+
+        assertThat(configuration.getPackageConfig("both").getScopedConstants())
+                .isEqualTo(Map.of("sample.scopable", "x", "second.scopable", 
"y"));
+    }
+
+    private Configuration load(String fixture) {
+        configurationManager = new 
ConfigurationManager(Container.DEFAULT_NAME);
+        configurationManager.addContainerProvider(new 
StrutsDefaultConfigurationProvider());
+        configurationManager.addContainerProvider(new 
StrutsXmlConfigurationProvider("org/apache/struts2/config/" + fixture));
+        return configurationManager.getConfiguration();
+    }
+}
+```
+
+- [ ] **Step 5: Run to verify they fail**
+
+Run: `./mvnw test -DskipAssembly -pl core -Dtest=ScopedConstantsValidationTest 
-Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo 
"exit=$?"; grep -E "Tests run|FAIL|Expecting" core/target/ww3476.log | head -20`
+Expected: `exit=1`; the three `...Fails`/`...Too` tests fail (no exception 
thrown); `registeredNameLoads` and `namesFromAllBeansAreScopable` pass.
+
+- [ ] **Step 6: Implement validation** — in `DefaultConfiguration.java`, add 
the import `org.apache.struts2.config.ScopableConstants` (`java.util.Set` and 
`java.util.TreeSet` are already imported). As the first statement of 
`buildRuntimeConfiguration()`:
+
+```java
+        validateScopedConstants();
+```
+
+And add, directly after `buildRuntimeConfiguration()`:
+
+```java
+    /**
+     * @since 7.5.0
+     */
+    protected void validateScopedConstants() throws ConfigurationException {
+        Set<String> scopableNames = ScopableConstants.collectNames(container);
+        for (PackageConfig packageConfig : packageContexts.values()) {
+            for (String name : packageConfig.getScopedConstants().keySet()) {
+                if (!scopableNames.contains(name)) {
+                    throw new ConfigurationException(String.format("Package 
[%s] declares scoped constant [%s], which is not scopable. %s",
+                            packageConfig.getName(), name, 
describeScopable(scopableNames)), packageConfig);
+                }
+            }
+        }
+    }
+
+    private static String describeScopable(Set<String> scopableNames) {
+        if (scopableNames.isEmpty()) {
+            return "No constants are scopable in this configuration.";
+        }
+        return "Scopable constants: " + new TreeSet<>(scopableNames) + ".";
+    }
+```
+
+- [ ] **Step 7: Run to verify they pass** — Step 5's command. Expected: 
`exit=0`, 5 tests, 0 failures.
+
+- [ ] **Step 8: Watch it fail** — with Edit, comment out the 
`validateScopedConstants();` call; `git diff --stat` shows 
`DefaultConfiguration.java`; re-run: the three failure-path tests FAIL. Revert; 
re-run: `exit=0`.
+
+- [ ] **Step 9: Regression check on config loading** — `./mvnw test 
-DskipAssembly -pl core 
-Dtest='*Configuration*Test,XmlConfigurationProvider*Test' 
-Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo 
"exit=$?"; grep -E "Tests run:.*Fail" core/target/ww3476.log | tail -3`. 
Expected `exit=0`.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/config/ScopableConstants.java \
+  core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java \
+  core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java \
+  core/src/test/java/org/apache/struts2/config/SecondScopableConstants.java \
+  
core/src/test/java/org/apache/struts2/config/ScopedConstantsValidationTest.java 
\
+  
core/src/test/resources/org/apache/struts2/config/scoped-constants-registered.xml
 \
+  
core/src/test/resources/org/apache/struts2/config/scoped-constants-unregistered.xml
 \
+  
core/src/test/resources/org/apache/struts2/config/scoped-constants-abstract-unregistered.xml
 \
+  
core/src/test/resources/org/apache/struts2/config/scoped-constants-no-registry.xml
 \
+  
core/src/test/resources/org/apache/struts2/config/scoped-constants-two-beans.xml
+git diff --cached --name-only
+git commit -m "WW-3476 feat(config): reject unregistered scoped constants at 
startup
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 4: `ScopedConstantProvider` and its wiring
+
+**Files:**
+- Create: 
`core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java`
+- Create: 
`core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java`
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (after 
`STRUTS_PARAMETER_ALLOWLISTER`, ~line 629)
+- Modify: 
`core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` 
(~line 456)
+- Modify: `core/src/main/resources/struts-beans.xml` (after the 
`ParameterAllowlister` bean, ~line 258)
+- Modify: 
`core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java`
 (`register` chain)
+- Create fixture: 
`core/src/test/resources/org/apache/struts2/config/scoped-constants-provider.xml`
+- Test: 
`core/src/test/java/org/apache/struts2/config/StrutsScopedConstantProviderTest.java`
 (new, JUnit 3, extends `XWorkTestCase`)
+
+**Interfaces:**
+- Consumes: `ScopableConstants.collectNames(Container)`, 
`SampleScopableConstants` (Task 3); `PackageConfig.getAllScopedConstants()` 
(Task 1); 7.5 DTD (Task 2).
+- Produces: `interface ScopedConstantProvider { String getValue(String name); 
}`; `StrutsScopedConstantProvider`; 
`StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER = 
"struts.scopedConstantProvider"`.
+
+- [ ] **Step 1: Create the contract** — `ScopedConstantProvider.java`:
+
+```java
+package org.apache.struts2.config;
+
+/**
+ * Resolves a scopable constant for the current action invocation: the 
package's {@code <scoped-constant>}, inherited
+ * through {@code extends}, or else the global constant.
+ *
+ * @since 7.5.0
+ */
+public interface ScopedConstantProvider {
+
+    /**
+     * @param name a constant registered through a {@link ScopableConstants} 
bean
+     * @return the scoped value, or the global value ({@code null} when there 
is none)
+     * @throws IllegalArgumentException when {@code name} is not scopable
+     */
+    String getValue(String name);
+}
+```
+
+- [ ] **Step 2: Create the fixture** — `scoped-constants-provider.xml`: the 
XML license header, then this DOCTYPE, then the body below.
+
+```xml
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "struts-7.5.dtd">
+```
+
+```xml
+<struts>
+    <bean type="org.apache.struts2.config.ScopableConstants" name="sample"
+          class="org.apache.struts2.config.SampleScopableConstants"/>
+
+    <constant name="sample.scopable" value="global"/>
+
+    <package name="base" abstract="true">
+        <scoped-constant name="sample.scopable" value="base"/>
+    </package>
+
+    <package name="child" namespace="/child" extends="base"/>
+
+    <package name="override" namespace="/override" extends="base">
+        <scoped-constant name="sample.scopable" value="override"/>
+    </package>
+
+    <package name="empty" namespace="/empty" extends="base">
+        <scoped-constant name="sample.scopable" value=""/>
+    </package>
+
+    <package name="plain" namespace="/plain"/>
+</struts>
+```
+
+- [ ] **Step 3: Write the failing tests** — 
`StrutsScopedConstantProviderTest.java`:
+
+```java
+package org.apache.struts2.config;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.XWorkTestCase;
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.mock.MockActionInvocation;
+import org.apache.struts2.mock.MockActionProxy;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class StrutsScopedConstantProviderTest extends XWorkTestCase {
+
+    private ScopedConstantProvider provider;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider("org/apache/struts2/config/scoped-constants-provider.xml"));
+        provider = container.getInstance(ScopedConstantProvider.class);
+    }
+
+    public void testDefaultProviderIsStrutsImplementation() {
+        assertTrue(provider instanceof StrutsScopedConstantProvider);
+    }
+
+    public void testPackageValueWinsInsideInvocation() {
+        enterPackage("override");
+
+        assertEquals("override", provider.getValue("sample.scopable"));
+    }
+
+    public void testInheritedValueResolvesForChildPackage() {
+        enterPackage("child");
+
+        assertEquals("base", provider.getValue("sample.scopable"));
+    }
+
+    public void testEmptyOverrideIsReturnedAsIs() {
+        enterPackage("empty");
+
+        assertEquals("", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutOverride() {
+        enterPackage("plain");
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutInvocation() {
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWithoutActionContext() {
+        ActionContext context = ActionContext.getContext();
+        ActionContext.clear();
+        try {
+            assertEquals("global", provider.getValue("sample.scopable"));
+        } finally {
+            context.bind();
+        }
+    }
+
+    public void testGlobalValueWhenInvocationHasNoProxy() {
+        ActionContext.getContext().withActionInvocation(new 
MockActionInvocation());
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWhenProxyHasNoConfig() {
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setProxy(new MockActionProxy());
+        ActionContext.getContext().withActionInvocation(invocation);
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testGlobalValueWhenPackageIsUnknown() {
+        enterPackage("missing");
+
+        assertEquals("global", provider.getValue("sample.scopable"));
+    }
+
+    public void testUnsetConstantWithoutOverrideIsNull() {
+        enterPackage("plain");
+
+        assertNull(provider.getValue("sample.unset"));
+    }
+
+    public void testUnregisteredNameThrows() {
+        assertThatThrownBy(() -> provider.getValue("sample.unknown"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[sample.unknown]");
+    }
+
+    public void testGlobalConstantUntouchedByScopedRead() {
+        enterPackage("override");
+        provider.getValue("sample.scopable");
+
+        assertEquals("global", container.getInstance(String.class, 
"sample.scopable"));
+    }
+
+    private void enterPackage(String packageName) {
+        MockActionProxy proxy = new MockActionProxy();
+        proxy.setConfig(new ActionConfig.Builder(packageName, "test", 
ActionSupport.class.getName()).build());
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setProxy(proxy);
+        ActionContext.getContext().withActionInvocation(invocation);
+    }
+}
+```
+
+- [ ] **Step 4: Run to verify they fail**
+
+Run: `./mvnw test -DskipAssembly -pl core 
-Dtest=StrutsScopedConstantProviderTest -Dsurefire.failIfNoSpecifiedTests=false 
> core/target/ww3476.log 2>&1; echo "exit=$?"; grep -E "ERROR|Tests run|cannot 
find symbol" core/target/ww3476.log | head -20`
+Expected: `exit=1`, `cannot find symbol ... StrutsScopedConstantProvider`.
+
+- [ ] **Step 5: Implement the default provider** — 
`StrutsScopedConstantProvider.java`:
+
+```java
+package org.apache.struts2.config;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ActionProxy;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.apache.struts2.inject.Container;
+import org.apache.struts2.inject.Inject;
+
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * @since 7.5.0
+ */
+public class StrutsScopedConstantProvider implements ScopedConstantProvider {
+
+    private Container container;
+    private Configuration configuration;
+    private volatile Set<String> scopableNames;
+
+    @Inject
+    public void setContainer(Container container) {
+        this.container = container;
+    }
+
+    @Inject
+    public void setConfiguration(Configuration configuration) {
+        this.configuration = configuration;
+    }
+
+    @Override
+    public String getValue(String name) {
+        if (!getScopableNames().contains(name)) {
+            throw new IllegalArgumentException(String.format(
+                    "Constant [%s] is not scopable; register it through a %s 
bean", name, ScopableConstants.class.getName()));
+        }
+        return currentPackage()
+                .map(packageConfig -> 
packageConfig.getAllScopedConstants().get(name))
+                .orElseGet(() -> container.getInstance(String.class, name));
+    }
+
+    private Optional<PackageConfig> currentPackage() {
+        ActionContext context = ActionContext.getContext();
+        ActionInvocation invocation = context != null ? 
context.getActionInvocation() : null;
+        ActionProxy proxy = invocation != null ? invocation.getProxy() : null;
+        if (proxy == null || proxy.getConfig() == null) {
+            return Optional.empty();
+        }
+        return 
Optional.ofNullable(configuration.getPackageConfig(proxy.getConfig().getPackageName()));
+    }
+
+    private Set<String> getScopableNames() {
+        Set<String> names = scopableNames;
+        if (names == null) {
+            names = ScopableConstants.collectNames(container);
+            scopableNames = names;
+        }
+        return names;
+    }
+}
+```
+
+Note: `Optional.map` treats a `null` mapping result as empty, so a package 
without the override falls through to `orElseGet`; an empty-string override is 
non-null and is returned as is.
+
+- [ ] **Step 6: Wire it**
+
+`StrutsConstants.java`, directly after `STRUTS_PARAMETER_ALLOWLISTER`:
+
+```java
+
+    /**
+     * The {@link org.apache.struts2.config.ScopedConstantProvider} 
implementation class.
+     *
+     * @since 7.5.0
+     */
+    public static final String STRUTS_SCOPED_CONSTANT_PROVIDER = 
"struts.scopedConstantProvider";
+```
+
+`StrutsBeanSelectionProvider.java`, directly after the `ParameterAllowlister` 
alias (same package — no import):
+
+```java
+        alias(ScopedConstantProvider.class, 
StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER, builder, props, 
Scope.SINGLETON);
+```
+
+`struts-beans.xml`, directly after the `ParameterAllowlister` bean:
+
+```xml
+
+    <bean type="org.apache.struts2.config.ScopedConstantProvider" name="struts"
+          class="org.apache.struts2.config.StrutsScopedConstantProvider" 
scope="singleton"/>
+```
+
+`StrutsDefaultConfigurationProvider.register`, directly after 
`.factory(UnknownHandlerManager.class, DefaultUnknownHandlerManager.class, 
Scope.SINGLETON)`:
+
+```java
+
+                .factory(ScopedConstantProvider.class, 
StrutsScopedConstantProvider.class, Scope.SINGLETON)
+```
+
+with imports `org.apache.struts2.config.ScopedConstantProvider` and 
`org.apache.struts2.config.StrutsScopedConstantProvider`.
+
+- [ ] **Step 7: Run to verify they pass** — Step 4's command. Expected: 
`exit=0`, 13 tests, 0 failures.
+
+- [ ] **Step 8: Watch it fail** — with Edit, replace `.map(packageConfig -> 
packageConfig.getAllScopedConstants().get(name))` with `.map(packageConfig -> 
(String) null)`; `git diff --stat` shows the file; re-run: 
`testPackageValueWinsInsideInvocation`, 
`testInheritedValueResolvesForChildPackage`, `testEmptyOverrideIsReturnedAsIs` 
FAIL. Revert. Then replace `.orElseGet(() -> 
container.getInstance(String.class, name))` with `.orElse(null)`; re-run: the 
`testGlobalValue*` tests FAIL. Revert [...]
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java \
+  
core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java \
+  core/src/main/java/org/apache/struts2/StrutsConstants.java \
+  
core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java \
+  core/src/main/resources/struts-beans.xml \
+  
core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java
 \
+  
core/src/test/java/org/apache/struts2/config/StrutsScopedConstantProviderTest.java
 \
+  
core/src/test/resources/org/apache/struts2/config/scoped-constants-provider.xml
+git diff --cached --name-only
+git commit -m "WW-3476 feat(config): add ScopedConstantProvider resolving 
package overrides
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 5: Full verification
+
+**Files:** none changed unless a regression appears.
+
+- [ ] **Step 1: Check for stray files** — `git status --short` must show no 
`??` entries from this work (review agents may leave probe tests; delete those, 
never commit them).
+
+- [ ] **Step 2: Run core and the Convention plugin** (Convention compares 
`PackageConfig`s with `equals`, which Task 1 changed):
+
+Run: `./mvnw test -DskipAssembly -pl core,plugins/convention > 
core/target/ww3476-full.log 2>&1; echo "exit=$?"; grep -E "Tests 
run:.*Fail|BUILD|ERROR\]" core/target/ww3476-full.log | tail -10`
+Expected: `exit=0`, `BUILD SUCCESS`. If a failure is in 
`DateTest.testJavaSqlDate` or another test unrelated to config, re-run that 
single test on `main` to confirm it is pre-existing before reporting it.
+
+- [ ] **Step 3: Confirm no adopter slipped in** — `grep -rn 
"ScopableConstants" core/src/main plugins/*/src/main` must list only 
`ScopableConstants.java`, `StrutsScopedConstantProvider.java`, 
`ScopedConstantProvider.java`, `DefaultConfiguration.java` — no `<bean 
type="...ScopableConstants"` in any `src/main` XML.
+
+- [ ] **Step 4: Hand back** — report the commits and test results. Not part of 
this plan (each needs the user's go-ahead): the `/security-review` pass and PR 
(`WW-3476 feat(config): add package-scoped constants`), and the struts-site PR 
publishing `dtds/struts-7.5.dtd` with docs for `<scoped-constant>`.
diff --git 
a/docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md 
b/docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md
new file mode 100644
index 000000000..8a5c08c55
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md
@@ -0,0 +1,182 @@
+# WW-3476 — Package-scoped constants
+
+**Ticket:** [WW-3476](https://issues.apache.org/jira/browse/WW-3476) — *Allow 
overwriting of constants for each package*
+**Target:** 7.5.0 (ticket fix version)
+**Date:** 2026-10-10
+**Status:** Design approved, pending implementation plan
+
+## Problem
+
+The ticket (2010, 2.2.1) asks to override constants per package — its examples 
are the ActionMapper, the locale
+and the JSP location. Struts constants cannot do this today:
+
+- Constants are global. They are injected once, at bootstrap, into container 
singletons
+  (`@Inject(StrutsConstants.X)` in ~30 core classes) or read via 
`container.getInstance(String.class, name)`.
+- `<package>` has no constant-like child (`struts-6.5.dtd`), and 
`PackageConfig` has nowhere to store one.
+
+A scoped value therefore cannot transparently replace what a singleton already 
holds. Making every constant
+package-aware would need a container per package — an 8.0-sized change. This 
design instead adds an opt-in
+mechanism that fits 7.x.
+
+## Goals
+
+- A package can declare an override for a constant; child packages inherit it 
through `extends`.
+- A component that wants a scoped value reads it at request time against the 
current invocation's package,
+  falling back to the global constant.
+- The shared singleton is never mutated — the same rule `WithLazyParams` 
follows since 7.3.0.
+- A misconfigured override fails at startup, never silently.
+- Purely additive: no published DTD, constructor or interface changes.
+
+## Non-goals
+
+- **No adopters in this change.** No existing constant becomes scopable. The 
mechanism ships alone; each adopter
+  (UI theme, i18n, Convention result path, …) is a follow-up ticket.
+- No `${...}` expressions — values are static strings.
+- No action-level scope (`<scoped-constant>` inside `<action>`).
+- No Convention-plugin annotation. Convention-built packages extend an XML 
parent package and inherit its
+  overrides.
+- No typed conversion — values are `String`, as with 
`container.getInstance(String.class, name)`.
+
+## Inherently non-scopable constants
+
+Anything consumed before the action is resolved can never be scoped, because 
the package is not yet known:
+the ActionMapper (it is what determines the package), multipart parsing limits 
(`struts.multipart.*`, applied
+in `Dispatcher` before mapping), and similar bootstrap/dispatch settings. The 
design does not special-case
+these — they are simply never registered as scopable. The per-URL-space 
ActionMapper need from the ticket is
+already served by `PrefixBasedActionMapper` (`struts.mapper.prefixMapping`, 
WW-3260).
+
+Security switches consumed at request time 
(`struts.parameters.requireAnnotations`, CSP nonce source,
+`struts.enable.DynamicMethodInvocation`, `devMode`) are technically scopable 
but should not be registered
+without a deliberate decision: a package-level override could quietly relax an 
application-wide hardening flag.
+
+## Design
+
+### 1. Configuration syntax — `struts-7.5.dtd`
+
+A new element, distinct from the global `<constant>` so it is never mistaken 
for one:
+
+```xml
+<!DOCTYPE struts PUBLIC
+    "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+    "https://struts.apache.org/dtds/struts-7.5.dtd";>
+<struts>
+    <package name="admin" extends="struts-default" namespace="/admin">
+        <scoped-constant name="some.scopable.constant" value="x"/>
+        <action name="..." class="..."/>
+    </package>
+</struts>
+```
+
+- New `core/src/main/resources/struts-7.5.dtd`: a copy of `struts-6.5.dtd` with
+  - `scoped-constant*` added first in the `package` content model;
+  - `<!ELEMENT scoped-constant EMPTY>` with `name CDATA #REQUIRED` and `value 
CDATA #REQUIRED`;
+  - the header comment's public id / URL updated to 7.5.
+- Registered in `StrutsXmlConfigurationProvider` next to the 6.5 mapping 
(`StrutsXmlConfigurationProvider.java:54`).
+- Published DTDs are unchanged. Precedent: WW-5409 added `struts-6.5.dtd` for 
the single `final` attribute.
+- The DTD name follows the ticket's fix version; if the release ships under 
another number, rename it with it.
+
+### 2. Storage — `PackageConfig`
+
+- New field: the package's own scoped constants, `Map<String, String>`, 
insertion-ordered.
+- `Builder.addScopedConstant(String name, String value)`.
+- `getScopedConstants()` — own declarations only (unmodifiable).
+- `getAllScopedConstants()` — merged map: parents first, then own entries, so 
the nearest declaration wins.
+  Multiple parents merge in the same `putAll` order as 
`getAllResultTypeConfigs()`.
+- The merged map is computed eagerly in `Builder.build()` and stored 
unmodifiable. Parents are always built
+  before their children, so the request-time lookup does not walk the 
hierarchy.
+- The copy constructor `PackageConfig(PackageConfig orig)` copies both maps.
+
+### 3. Parsing — `XmlDocConfigurationProvider`
+
+- `addPackage(Element)` reads `scoped-constant` children and calls 
`addScopedConstant`.
+- A duplicate `name` within one package is a `ConfigurationException` carrying 
the element's location.
+- Under an older DOCTYPE, the validating parser already rejects the element — 
no extra code.
+
+### 4. Registry — `ScopableConstants`
+
+```java
+package org.apache.struts2.config;
+
+public interface ScopableConstants {
+    Set<String> getNames();
+}
+```
+
+- Adopters register a named bean, in `struts-beans.xml` (core) or a plugin's 
`struts-plugin.xml`:
+  `<bean type="org.apache.struts2.config.ScopableConstants" name="ui" 
class="...UiScopableConstants"/>`
+- The registry is the union of all registered beans' names, collected via
+  `container.getInstanceNames(ScopableConstants.class)` — the existing pattern 
for `UnknownHandler`,
+  `FileManager` and `PackageProvider`.
+- This change registers no beans; only tests do.
+
+### 5. Resolution — `ScopedConstantProvider`
+
+```java
+package org.apache.struts2.config;
+
+public interface ScopedConstantProvider {
+    String getValue(String name);
+}
+```
+
+Default implementation `StrutsScopedConstantProvider`, wired like every other 
pluggable core service
+(`ParameterAuthorizer`, `OgnlGuard`): `name="struts"` in `struts-beans.xml`, 
aliased in `StrutsBeanSelectionProvider`
+through a new `StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER = 
"struts.scopedConstantProvider"`, and registered
+in `StrutsDefaultConfigurationProvider` for the test harness. It injects 
`Container` and `Configuration` (the
+latter is a container factory, `DefaultConfiguration.java:291`). 
`getValue(name)`:
+
+1. `name` not in the registry → `IllegalArgumentException`. Only an adopter's 
coding error reaches this; user
+   configuration cannot.
+2. No `ActionContext`, or no `ActionInvocation` on it (bootstrap, a JSP 
rendered without an action) → the global
+   value, `container.getInstance(String.class, name)`.
+3. Otherwise: the invocation's `ActionConfig.getPackageName()` → 
`configuration.getPackageConfig(...)` →
+   `getAllScopedConstants().get(name)`; when absent, the global value.
+
+Config reload needs no extra code: the container and `Configuration` are 
rebuilt, and lookups read the new
+`PackageConfig`s.
+
+### 6. Validation — `DefaultConfiguration.rebuildRuntimeConfiguration()`
+
+After all package providers have loaded, for every package, each name in 
`getScopedConstants()` (own
+declarations — inherited ones were validated on the parent) must be in the 
registry. Otherwise a
+`ConfigurationException`:
+
+> Package [admin] declares scoped constant [some.constant], which is not 
scopable. Scopable constants: [a, b].
+
+or, when the registry is empty, "No constants are scopable in this 
configuration." The exception carries the
+package's location (`PackageConfig` is `Located`); per-element locations are 
not stored.
+
+## Testing
+
+JUnit 3/4 only — match each target file's existing style; no Jupiter.
+
+- **Parsing** — `XmlConfigurationProviderPackagesTest` (extends 
`ConfigurationTestBase`), with new fixtures next to
+  `xwork-test-package-final.xml`:
+  - a 7.5 fixture with `<scoped-constant>` loads, and the values land on 
`PackageConfig`;
+  - a duplicate name in one package fails with `ConfigurationException`;
+  - `<scoped-constant>` under the 6.5 DOCTYPE fails validation;
+  - existing 6.5/6.0/2.x fixtures still load (covered by the current suite).
+- **Merging** — `PackageConfigTest`: child overrides parent; grandchild 
inherits through two levels; a package
+  with no declarations inherits everything; the multiple-parents winner is 
pinned.
+- **Validation** — a test-only `ScopableConstants` bean: a registered name 
loads; an unregistered name throws with
+  package and constant in the message; with no beans registered, any 
`<scoped-constant>` throws.
+- **Provider** — `StrutsScopedConstantProviderTest`: the package value wins 
inside an invocation; the global value
+  is returned with no context, no invocation, or no override; an inherited 
value resolves for a child package;
+  an unregistered name throws; the global `container.getInstance(String.class, 
name)` is unchanged after a scoped
+  read.
+- **Watch it fail** — mutate each guarded behaviour (merge order, the 
validation call, the fallback) with Edit,
+  confirm via `git diff --stat`, see the test go red, restore.
+
+## Compatibility
+
+Additive only: a new DTD, a new element, two new interfaces, one new default 
bean with its selection constant,
+new `PackageConfig` methods.
+Existing constructors, builders, interfaces and published DTDs are untouched — 
suitable for the 7.x line.
+
+## Delivery
+
+1. Core PR: `WW-3476 feat(config): add package-scoped constants`.
+2. struts-site PR: publish `dtds/struts-7.5.dtd`, and document 
`<scoped-constant>` and the adopter API
+   (`WW-3476 docs: ...`). The public DTD URL must resolve before the release 
ships.
+3. Version Notes entry at release time.
+4. Follow-up tickets, one per adopter, filed when wanted.

Reply via email to