diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java
new file mode 100644
index 00000000..30482877
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java
@@ -0,0 +1,68 @@
+package com.cameleer.server.app.license;
+
+import com.cameleer.server.core.admin.AuditCategory;
+import com.cameleer.server.core.admin.AuditResult;
+import com.cameleer.server.core.admin.AuditService;
+import com.cameleer.server.core.license.LicenseGate;
+import com.cameleer.server.core.license.LicenseLimits;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Single entry point for license cap enforcement (spec ยง4).
+ *
+ *
Consults {@link LicenseGate#getEffectiveLimits()} (license-overrides UNION default tier when
+ * ACTIVE/GRACE; defaults-only otherwise) and rejects calls whose projected usage would exceed the
+ * cap. Rejections increment a per-limit Micrometer counter and, when an {@link AuditService} is
+ * wired, emit an {@link AuditCategory#LICENSE} {@code cap_exceeded} audit row.
+ *
+ * Unknown limit keys are treated as programmer errors and surface as
+ * {@link IllegalArgumentException} (propagated from {@link LicenseLimits#get(String)}), not
+ * {@link LicenseCapExceededException}.
+ */
+@Component
+public class LicenseEnforcer {
+
+ private final LicenseGate gate;
+ private final MeterRegistry meters;
+ private final AuditService audit;
+ private final ConcurrentMap rejectionCounters = new ConcurrentHashMap<>();
+
+ public LicenseEnforcer(LicenseGate gate, MeterRegistry meters, AuditService audit) {
+ this.gate = gate;
+ this.meters = meters;
+ this.audit = audit;
+ }
+
+ /** Test-only ctor with no metrics or audit. */
+ public LicenseEnforcer(LicenseGate gate) {
+ this(gate, new SimpleMeterRegistry(), null);
+ }
+
+ public void assertWithinCap(String limitKey, long currentUsage, long requestedDelta) {
+ LicenseLimits effective = gate.getEffectiveLimits();
+ int cap = effective.get(limitKey); // throws IllegalArgumentException if unknown key
+ long projected = currentUsage + requestedDelta;
+ if (projected > cap) {
+ rejectionCounters.computeIfAbsent(limitKey, k -> Counter.builder("cameleer_license_cap_rejections_total")
+ .tag("limit", k).register(meters)).increment();
+ if (audit != null) {
+ Map detail = new LinkedHashMap<>();
+ detail.put("limit", limitKey);
+ detail.put("current", currentUsage);
+ detail.put("requested", requestedDelta);
+ detail.put("cap", cap);
+ detail.put("state", gate.getState().name());
+ audit.log("system", "cap_exceeded", AuditCategory.LICENSE, limitKey, detail, AuditResult.FAILURE, null);
+ }
+ throw new LicenseCapExceededException(limitKey, projected, cap);
+ }
+ }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java
new file mode 100644
index 00000000..db23264a
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java
@@ -0,0 +1,51 @@
+package com.cameleer.server.app.license;
+
+import com.cameleer.server.core.license.LicenseGate;
+import com.cameleer.server.core.license.LicenseInfo;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class LicenseEnforcerTest {
+
+ @Test
+ void underCap_passes() {
+ LicenseGate gate = new LicenseGate();
+ gate.load(license(Map.of("max_apps", 10), 0));
+ new LicenseEnforcer(gate).assertWithinCap("max_apps", 9, 1);
+ }
+
+ @Test
+ void atCap_throws() {
+ LicenseGate gate = new LicenseGate();
+ gate.load(license(Map.of("max_apps", 10), 0));
+ assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 10, 1))
+ .isInstanceOf(LicenseCapExceededException.class)
+ .hasMessageContaining("max_apps");
+ }
+
+ @Test
+ void absent_usesDefaultTier() {
+ LicenseGate gate = new LicenseGate();
+ // default max_apps = 3; current 3 + 1 > 3 -> reject
+ assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 3, 1))
+ .isInstanceOf(LicenseCapExceededException.class);
+ }
+
+ @Test
+ void unknownLimitKey_throwsIllegalArgument() {
+ LicenseGate gate = new LicenseGate();
+ assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_xyz", 0, 1))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ private LicenseInfo license(Map limits, int grace) {
+ return new LicenseInfo(UUID.randomUUID(), "acme", null,
+ limits, Instant.now(), Instant.now().plusSeconds(86400), grace);
+ }
+}