diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java
new file mode 100644
index 00000000..049c6ec0
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java
@@ -0,0 +1,67 @@
+package com.cameleer.server.app.license;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Read-side usage snapshot used by the /api/v1/admin/license/usage endpoint and license metrics.
+ *
+ *
Counts come straight from PostgreSQL row counts; compute aggregates SUM over
+ * non-stopped deployments and read replica/cpu/memory from the
+ * {@code deployed_config_snapshot.containerConfig} JSONB sub-object. Pre-RUNNING deployments
+ * (STARTING with no snapshot yet) contribute defaults (1 replica, 0 cpu, 0 memory) until they
+ * roll forward.
+ *
+ * {@code max_agents} is not in PG — the registry is in-memory; callers feed the live count
+ * into {@link #agentCount(int)} which echoes it for assembly into the snapshot map.
+ */
+@Component
+public class LicenseUsageReader {
+
+ private final JdbcTemplate jdbc;
+
+ public LicenseUsageReader(JdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public Map snapshot() {
+ Map out = new LinkedHashMap<>();
+ out.put("max_environments", count("environments"));
+ out.put("max_apps", count("apps"));
+ out.put("max_users", count("users"));
+ out.put("max_outbound_connections", count("outbound_connections"));
+ out.put("max_alert_rules", count("alert_rules"));
+ Map compute = jdbc.queryForObject(
+ "SELECT " +
+ " COALESCE(SUM(replicas * cpu_millis), 0) AS cpu, " +
+ " COALESCE(SUM(replicas * memory_mb), 0) AS mem, " +
+ " COALESCE(SUM(replicas), 0) AS reps " +
+ "FROM ( " +
+ " SELECT " +
+ " COALESCE((d.deployed_config_snapshot->'containerConfig'->>'replicas')::int, 1) AS replicas, " +
+ " COALESCE((d.deployed_config_snapshot->'containerConfig'->>'cpuLimit')::int, 0) AS cpu_millis, " +
+ " COALESCE((d.deployed_config_snapshot->'containerConfig'->>'memoryLimitMb')::int, 0) AS memory_mb " +
+ " FROM deployments d " +
+ " WHERE d.status IN ('STARTING','RUNNING','DEGRADED','STOPPING') " +
+ ") s",
+ (rs, n) -> Map.of(
+ "max_total_cpu_millis", rs.getLong("cpu"),
+ "max_total_memory_mb", rs.getLong("mem"),
+ "max_total_replicas", rs.getLong("reps")
+ ));
+ out.putAll(compute);
+ return out;
+ }
+
+ /** Echoes the live agent count fed in by the controller (registry is in-memory). */
+ public long agentCount(int liveAgents) {
+ return liveAgents;
+ }
+
+ private long count(String table) {
+ return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
+ }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java
new file mode 100644
index 00000000..896c8a84
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java
@@ -0,0 +1,20 @@
+package com.cameleer.server.app.license;
+
+import com.cameleer.server.app.AbstractPostgresIT;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LicenseUsageReaderIT extends AbstractPostgresIT {
+
+ @Autowired LicenseUsageReader reader;
+
+ @Test
+ void emptyDb_returnsZeros() {
+ var snap = reader.snapshot();
+ assertThat(snap.get("max_apps")).isEqualTo(0L);
+ assertThat(snap.get("max_environments")).isLessThanOrEqualTo(1L); // V1 seeds default env
+ assertThat(snap.get("max_total_cpu_millis")).isEqualTo(0L);
+ }
+}