From ec51aef80219d2646e229a69505843dfcf56ef0d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:09:28 +0200 Subject: [PATCH] docs(license): implementation plan for license enforcement 36 tasks covering: dead-Feature removal; LicenseInfo/Limits/State machine; standalone cameleer-license-minter Maven module + CLI with --verify; Flyway V5 license table + environments retention columns; LicenseRepository/Service/Enforcer/UsageReader; per-state cap-rejection ControllerAdvice with rendered messages; wiring across Environment/ App/Agent/User/Outbound/AlertRule/Deployment compute caps; runtime ClickHouse TTL applier on every LicenseChangedEvent; daily revalidation job; usage endpoint; Prometheus gauges; ITs; OpenAPI regen; .claude/rules updates. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-25-license-enforcement.md | 4083 +++++++++++++++++ 1 file changed, 4083 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-25-license-enforcement.md diff --git a/docs/superpowers/plans/2026-04-25-license-enforcement.md b/docs/superpowers/plans/2026-04-25-license-enforcement.md new file mode 100644 index 00000000..df559e67 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-license-enforcement.md @@ -0,0 +1,4083 @@ +# License Enforcement — 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:** Enforce arbitrary per-customer license limits (entity counts, compute, retention) on a default-tier-by-default server, with a vendor-only standalone minter and PostgreSQL-persisted licenses. + +**Architecture:** Three modules — `cameleer-server-core` carries the validator/state-machine/defaults, `cameleer-server-app` carries the enforcement, persistence, REST, and ClickHouse-TTL applier, and a new `cameleer-license-minter` top-level Maven module (vendor-only, not in the runtime tree) carries the signing primitive and CLI. License envelope is a base64(payload).base64(ed25519) token; runtime install via `POST /admin/license` writes through to PG; an event bus recomputes ClickHouse TTL on every license change; a daily revalidation job refreshes `last_validated_at` and catches public-key drift. + +**Tech Stack:** Java 17, Spring Boot 3.4.3, JdbcTemplate over PostgreSQL (Flyway), ClickHouse (jdbc), Ed25519 (`java.security.Signature`), Jackson canonical JSON, JUnit 5 + AssertJ + Mockito, Testcontainers Postgres + ClickHouse, REST-Assured-style `MockMvc` for IT. + +**Spec:** `docs/superpowers/specs/2026-04-25-license-enforcement-design.md` + +--- + +## Conventions for the executor + +- **Before editing any existing class**, run `gitnexus_impact({target: "ClassName", direction: "upstream"})` and report blast radius. Refuse HIGH/CRITICAL without confirming. +- **Before each commit**, run `gitnexus_detect_changes()` to verify scope. +- After committing, run `npx gitnexus analyze --embeddings` (PostToolUse hook may automate). +- Per-test runs: `mvn -pl cameleer-server-core -Dtest=ClassName#method test` or `mvn -pl cameleer-server-app -Dtest=ClassName#method test`. +- Full IT run (slow, Testcontainers): `mvn -pl cameleer-server-app verify`. +- Fast unit-only: `mvn -pl cameleer-server-core test && mvn -pl cameleer-server-app test -DskipITs`. +- After any controller signature change in this plan, regenerate OpenAPI types (Task 35). +- After adding/removing/renaming classes/controllers, update `.claude/rules/*.md` (Task 36). +- Commit per step that says "Commit". Use `feat(license):`, `fix(license):`, `refactor(license):`, `test(license):`, `docs(license):` prefixes. End every commit message body with `Co-Authored-By: Claude Opus 4.7 (1M context) `. + +--- + +## Files touched (summary) + +**Created:** +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java` (enum) +- `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java` +- `cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java` +- `cameleer-license-minter/pom.xml` +- `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java` +- `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` +- `cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java` +- `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` +- `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java` + +**Modified:** +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` (drop `features`, add `licenseId`/`tenantId`/`gracePeriodDays`; require fields) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` (require new fields, reject tenant mismatch via constructor arg, drop `features`) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` (drop `isEnabled(Feature)`; add `getEffectiveLimits()`/`getState()`) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java` (add 3 retention fields) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` (cap retention values via enforcer hook) +- `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` (boot order: env > file > DB; publish event) +- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` (persist + audit + return state) +- `cameleer-server-app/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` (add `LICENSE`) +- All "wire enforcement" call sites listed in Tasks 18–25 +- `pom.xml` (root: register `cameleer-license-minter` module) +- `.claude/rules/core-classes.md`, `.claude/rules/app-classes.md` (Task 36) +- `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` (Task 35) + +**Deleted:** +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` +- The `features` portions of `LicenseInfo`, `LicenseGate`, `LicenseValidator` +- `LicenseGateTest#withLicense_onlyLicensedFeaturesEnabled` + +--- + +## Task 1: Remove dead `Feature` scaffolding (first commit) + +**Why:** Spec §9 — `Feature` enum is removed before any new code lands so subsequent edits don't have to maintain a dual world. + +**Files:** +- Delete: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` + +- [ ] **Step 1.1: Run gitnexus impact for `Feature`, `LicenseGate.isEnabled`, `LicenseInfo.hasFeature`** + +``` +gitnexus_impact({target: "Feature", direction: "upstream"}) +gitnexus_impact({target: "isEnabled", direction: "upstream"}) +gitnexus_impact({target: "hasFeature", direction: "upstream"}) +``` +Expected: only the LicenseGate/LicenseInfo classes themselves and tests reference these — no production callers. + +- [ ] **Step 1.2: Delete `Feature.java`** + +```bash +rm cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java +``` + +- [ ] **Step 1.3: Replace `LicenseInfo.java` with the new shape (still without `licenseId`/`tenantId` — Task 2 adds those)** + +```java +package com.cameleer.server.core.license; + +import java.time.Instant; +import java.util.Map; + +public record LicenseInfo( + String tier, + Map limits, + Instant issuedAt, + Instant expiresAt +) { + public boolean isExpired() { + return expiresAt != null && Instant.now().isAfter(expiresAt); + } + + public int getLimit(String key, int defaultValue) { + return limits.getOrDefault(key, defaultValue); + } + + public static LicenseInfo open() { + return new LicenseInfo("open", Map.of(), Instant.now(), null); + } +} +``` + +- [ ] **Step 1.4: Replace `LicenseGate.java` (drop `isEnabled`)** + +```java +package com.cameleer.server.core.license; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicReference; + +public class LicenseGate { + + private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); + + private final AtomicReference current = new AtomicReference<>(LicenseInfo.open()); + + public void load(LicenseInfo license) { + current.set(license); + log.info("License loaded: tier={}, limits={}, expires={}", + license.tier(), license.limits(), license.expiresAt()); + } + + public String getTier() { + return current.get().tier(); + } + + public int getLimit(String key, int defaultValue) { + return current.get().getLimit(key, defaultValue); + } + + public LicenseInfo getCurrent() { + return current.get(); + } +} +``` + +- [ ] **Step 1.5: Update `LicenseValidator.java` — drop the `features` parsing block** + +In the `validate(String token)` method, delete the entire `Set features = ...` block (lines 59–68 of the original) and remove the corresponding constructor arg from the `new LicenseInfo(...)` call so it matches Step 1.3's shape: + +```java +LicenseInfo info = new LicenseInfo(tier, limits, issuedAt, expiresAt); +``` + +Also remove the `import java.util.HashSet;` if no other usage remains. + +- [ ] **Step 1.6: Update `LicenseGateTest.java`** + +Delete `withLicense_onlyLicensedFeaturesEnabled()` and `noLicense_allFeaturesEnabled()` entirely. Replace the file with: + +```java +package com.cameleer.server.core.license; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseGateTest { + + @Test + void noLicense_returnsOpenTier() { + LicenseGate gate = new LicenseGate(); + assertThat(gate.getTier()).isEqualTo("open"); + assertThat(gate.getLimit("max_apps", 99)).isEqualTo(99); + } + + @Test + void loaded_exposesLimits() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo("MID", + Map.of("max_agents", 10, "retention_days", 30), + Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS)); + gate.load(info); + + assertThat(gate.getTier()).isEqualTo("MID"); + assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10); + assertThat(gate.getLimit("missing", 7)).isEqualTo(7); + } +} +``` + +- [ ] **Step 1.7: Update `LicenseValidatorTest.java` — remove `features` from sample payloads and `Feature` assertions** + +Replace the JSON payload string in `validate_validLicense_returnsLicenseInfo` with: + +```java +String payload = """ + {"tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d} + """.formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); +``` + +Delete the lines: + +```java +assertThat(info.hasFeature(Feature.debugger)).isTrue(); +assertThat(info.hasFeature(Feature.replay)).isFalse(); +``` + +In `validate_expiredLicense_throwsException`, replace its payload with: + +```java +String payload = """ + {"tier":"LOW","limits":{},"iat":%d,"exp":%d} + """.formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); +``` + +In `validate_tamperedPayload_throwsException`, replace its payload with: + +```java +String payload = """ + {"tier":"LOW","limits":{},"iat":0,"exp":9999999999} + """.trim(); +``` + +Remove the `import com.cameleer.server.core.license.Feature;` if any. + +- [ ] **Step 1.8: Build core unit tests** + +Run: `mvn -pl cameleer-server-core test` +Expected: BUILD SUCCESS, 3 tests in `LicenseValidatorTest`, 2 in `LicenseGateTest`. + +- [ ] **Step 1.9: Build app unit tests (only the moved tests; ITs skipped)** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: BUILD SUCCESS — no other code touched yet. + +- [ ] **Step 1.10: Commit** + +```bash +git add -u cameleer-server-core cameleer-server-app +git commit -m "$(cat <<'EOF' +refactor(license): remove dead Feature enum and isEnabled scaffolding + +Spec §9 — feature flags are out of scope for license enforcement. +Drops Feature.java, LicenseGate.isEnabled, LicenseInfo.hasFeature, +and the corresponding test cases. LicenseValidator now silently +ignores any features array on the wire (no error). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Expand `LicenseInfo` schema — required `licenseId`, `tenantId`, `gracePeriodDays` + +**Why:** Spec §2 — the new envelope adds three fields. `licenseId` and `tenantId` are required for audit and anti-portability respectively. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` +- Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java` (NEW) + +- [ ] **Step 2.1: Write failing test — `LicenseInfoTest.java`** + +Create the file with: + +```java +package com.cameleer.server.core.license; + +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 LicenseInfoTest { + + @Test + void requiresLicenseId() { + assertThatThrownBy(() -> new LicenseInfo( + null, "acme", "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("licenseId"); + } + + @Test + void requiresTenantId() { + assertThatThrownBy(() -> new LicenseInfo( + UUID.randomUUID(), null, "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("tenantId"); + } + + @Test + void emptyTenantIdRejected() { + assertThatThrownBy(() -> new LicenseInfo( + UUID.randomUUID(), " ", "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getLimit_returnsDefaultWhenMissing() { + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), "acme", null, + Map.of("max_apps", 5), Instant.now(), + Instant.now().plusSeconds(60), 0); + assertThat(info.getLimit("max_apps", 99)).isEqualTo(5); + assertThat(info.getLimit("max_users", 99)).isEqualTo(99); + } + + @Test + void isExpired_honoursGracePeriod() { + Instant pastByTen = Instant.now().minusSeconds(10 * 86400); + LicenseInfo withinGrace = new LicenseInfo( + UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(40 * 86400), + pastByTen, 30); + assertThat(withinGrace.isExpired()).isFalse(); // 10 days into a 30-day grace + LicenseInfo pastGrace = new LicenseInfo( + UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(40 * 86400), + pastByTen, 5); + assertThat(pastGrace.isExpired()).isTrue(); // 10 days is past the 5-day grace + } +} +``` + +- [ ] **Step 2.2: Run — expect compile failure** + +Run: `mvn -pl cameleer-server-core test -Dtest=LicenseInfoTest` +Expected: FAIL — `LicenseInfo` constructor signature does not match. + +- [ ] **Step 2.3: Update `LicenseInfo.java` to the new shape** + +```java +package com.cameleer.server.core.license; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public record LicenseInfo( + UUID licenseId, + String tenantId, + String label, + Map limits, + Instant issuedAt, + Instant expiresAt, + int gracePeriodDays +) { + public LicenseInfo { + Objects.requireNonNull(licenseId, "licenseId is required"); + Objects.requireNonNull(tenantId, "tenantId is required"); + Objects.requireNonNull(limits, "limits is required"); + Objects.requireNonNull(issuedAt, "issuedAt is required"); + Objects.requireNonNull(expiresAt, "expiresAt is required"); + if (tenantId.isBlank()) { + throw new IllegalArgumentException("tenantId must not be blank"); + } + if (gracePeriodDays < 0) { + throw new IllegalArgumentException("gracePeriodDays must be >= 0"); + } + } + + public boolean isExpired() { + Instant deadline = expiresAt.plusSeconds((long) gracePeriodDays * 86400); + return Instant.now().isAfter(deadline); + } + + public boolean isAfterRawExpiry() { + return Instant.now().isAfter(expiresAt); + } + + public int getLimit(String key, int defaultValue) { + return limits.getOrDefault(key, defaultValue); + } +} +``` + +Note: `LicenseInfo.open()` is removed — `LicenseGate` will switch to a sentinel `null` for ABSENT in Task 5. + +- [ ] **Step 2.4: Run the test** + +Run: `mvn -pl cameleer-server-core test -Dtest=LicenseInfoTest` +Expected: PASS, 5/5. + +- [ ] **Step 2.5: Fix the broken callers from Task 1 (transient compile failures)** + +`LicenseValidator.validate(...)` still does `new LicenseInfo(tier, limits, issuedAt, expiresAt)`. Task 3 rewrites it; for now, replace just that line with a placeholder so the module compiles: + +```java +LicenseInfo info = new LicenseInfo( + java.util.UUID.randomUUID(), "placeholder", null, + limits, issuedAt, expiresAt, 0); +``` + +Mark this with a `// TODO Task 3` comment so the executor cannot forget. + +`LicenseGate` references `LicenseInfo.open()`. Add a temporary inline stub at the top of `LicenseGate`: + +```java +private static LicenseInfo openSentinel() { + return new LicenseInfo(java.util.UUID.randomUUID(), "open", null, + Map.of(), Instant.EPOCH, Instant.MAX, 0); +} +``` + +And use `openSentinel()` instead of `LicenseInfo.open()` in the field initialiser. This is also TODO-marked for Task 5 to replace. + +`LicenseGateTest` (from Task 1.6) uses the old 4-arg constructor; update its `new LicenseInfo(...)` call to the 7-arg form using the same placeholder pattern. `LicenseValidatorTest` builds JSON, not Java objects — no change needed. + +- [ ] **Step 2.6: Build & test** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS — the placeholders compile and existing tests assert nothing about the placeholder fields. + +- [ ] **Step 2.7: Commit** + +```bash +git add cameleer-server-core/src +git commit -m "$(cat <<'EOF' +feat(license): expand LicenseInfo with licenseId, tenantId, grace period + +Required fields per spec §2.1. tenantId is non-blank; gracePeriodDays +defines the post-exp window during which limits keep applying. +isExpired() now honours the grace; isAfterRawExpiry() distinguishes +ACTIVE from GRACE for the state machine in Task 4. + +Validator and gate use placeholder values temporarily; Task 3 wires +the validator to read the new fields, Task 5 rewrites the gate. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Rewrite `LicenseValidator` for the new envelope (require `licenseId`/`tenantId`, optional tenant binding check) + +**Why:** Spec §2.1 + §6.4 — validator must reject tokens missing required fields and tokens whose `tenantId` does not match the configured server tenant. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` + +- [ ] **Step 3.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "LicenseValidator", direction: "upstream"}) +``` +Expected: `LicenseBeanConfig`, `LicenseAdminController`, the test class. All will be touched downstream. + +- [ ] **Step 3.2: Write failing tests in `LicenseValidatorTest.java`** + +Add three new test methods (keep the existing three, but update their JSON payloads to include the new required fields): + +```java +@Test +void validate_missingTenantId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); +} + +@Test +void validate_tenantIdMismatch_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); +} + +@Test +void validate_missingLicenseId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("licenseId"); +} +``` + +Update `validate_validLicense_returnsLicenseInfo` payload to include both required fields: + +```java +String payload = """ + {"licenseId":"%s","tenantId":"acme","tier":"HIGH","limits":{"max_agents":50},"iat":%d,"exp":%d,"gracePeriodDays":7} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); +``` + +And construct the validator with the matching tenant: + +```java +LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); +``` + +Add asserts: + +```java +assertThat(info.tenantId()).isEqualTo("acme"); +assertThat(info.gracePeriodDays()).isEqualTo(7); +``` + +Apply the same `tenantId`/`licenseId` additions to `validate_expiredLicense_throwsException` and `validate_tamperedPayload_throwsException` payloads. Add `import java.util.UUID;` at the top. + +- [ ] **Step 3.3: Run — expect failures** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseValidatorTest` +Expected: FAIL — `LicenseValidator` ctor takes only one arg. + +- [ ] **Step 3.4: Rewrite `LicenseValidator.java`** + +```java +package com.cameleer.server.core.license; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.*; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; +import java.util.*; + +public class LicenseValidator { + + private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final PublicKey publicKey; + private final String expectedTenantId; + + public LicenseValidator(String publicKeyBase64, String expectedTenantId) { + Objects.requireNonNull(expectedTenantId, "expectedTenantId is required"); + if (expectedTenantId.isBlank()) { + throw new IllegalArgumentException("expectedTenantId must not be blank"); + } + try { + byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); + KeyFactory kf = KeyFactory.getInstance("Ed25519"); + this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes)); + } catch (Exception e) { + throw new IllegalStateException("Failed to load license public key", e); + } + this.expectedTenantId = expectedTenantId; + } + + public LicenseInfo validate(String token) { + String[] parts = token.split("\\.", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid license token format: expected payload.signature"); + } + + byte[] payloadBytes = Base64.getDecoder().decode(parts[0]); + byte[] signatureBytes = Base64.getDecoder().decode(parts[1]); + + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(payloadBytes); + if (!verifier.verify(signatureBytes)) { + throw new SecurityException("License signature verification failed"); + } + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + throw new SecurityException("License signature verification failed", e); + } + + try { + JsonNode root = objectMapper.readTree(payloadBytes); + + String licenseIdStr = textOrThrow(root, "licenseId"); + UUID licenseId; + try { + licenseId = UUID.fromString(licenseIdStr); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr); + } + + String tenantId = textOrThrow(root, "tenantId"); + if (!tenantId.equals(expectedTenantId)) { + throw new IllegalArgumentException( + "License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'"); + } + + String tier = root.has("tier") ? root.get("tier").asText() : "unspecified"; + String label = root.has("label") ? root.get("label").asText() : null; + + Map limits = new HashMap<>(); + if (root.has("limits")) { + root.get("limits").fields().forEachRemaining(entry -> + limits.put(entry.getKey(), entry.getValue().asInt())); + } + + Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now(); + if (!root.has("exp")) { + throw new IllegalArgumentException("exp is required"); + } + Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong()); + int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0; + + LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays); + + if (info.isExpired()) { + throw new IllegalArgumentException("License expired at " + expiresAt + + " (grace period " + gracePeriodDays + " days)"); + } + + return info; + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse license payload", e); + } + } + + private static String textOrThrow(JsonNode root, String field) { + if (!root.has(field) || root.get(field).asText().isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return root.get(field).asText(); + } +} +``` + +Note `tier` is now optional with a default — spec calls it a label-only field; we keep parsing it for backward compatibility with old hand-written tokens. + +- [ ] **Step 3.5: Run validator tests** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseValidatorTest` +Expected: PASS, 6/6. + +- [ ] **Step 3.6: Update `LicenseBeanConfig` — pass tenant id to validator (compile fix)** + +In `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java`, inject the tenant id and update the two `new LicenseValidator(...)` calls: + +```java +@Value("${cameleer.server.tenant.id:default}") +private String tenantId; +``` + +And change both call sites: + +```java +LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId); +``` + +Apply the same change to `LicenseAdminController.java` line 45 (use `@Value("${cameleer.server.tenant.id:default}")` injected via the constructor). + +- [ ] **Step 3.7: Build app** + +Run: `mvn -pl cameleer-server-app compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3.8: Commit** + +```bash +git add cameleer-server-core cameleer-server-app +git commit -m "$(cat <<'EOF' +feat(license): require licenseId + tenantId in validator + +Spec §2.1 — both fields are required and the validator rejects a +token whose tenantId does not match the server's configured tenant +(CAMELEER_SERVER_TENANT_ID). Self-hosted customers cannot strip +tenantId because the field is in the signed payload. + +LicenseBeanConfig and LicenseAdminController updated to pass the +expected tenant to the validator constructor. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Add `LicenseLimits`, `DefaultTierLimits`, `LicenseState`, `LicenseStateMachine` + +**Why:** Spec §3 — pure FSM + the default-tier constants live in `core` so app and minter can both use them. + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java` +- Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java` +- Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java` + +- [ ] **Step 4.1: Write failing test — `DefaultTierLimitsTest.java`** + +```java +package com.cameleer.server.core.license; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultTierLimitsTest { + + @Test + void allDocumentedKeysHaveDefaults() { + for (String key : new String[]{ + "max_environments", "max_apps", "max_agents", "max_users", + "max_outbound_connections", "max_alert_rules", + "max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas", + "max_execution_retention_days", "max_log_retention_days", + "max_metric_retention_days", "max_jar_retention_count" + }) { + assertThat(DefaultTierLimits.DEFAULTS).containsKey(key); + } + } + + @Test + void specificValues() { + assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1); + assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3); + assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5); + assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000); + assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1); + } +} +``` + +- [ ] **Step 4.2: Run — expect compile failure** + +Run: `mvn -pl cameleer-server-core test -Dtest=DefaultTierLimitsTest` +Expected: FAIL — class missing. + +- [ ] **Step 4.3: Create `DefaultTierLimits.java`** + +```java +package com.cameleer.server.core.license; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DefaultTierLimits { + + public static final Map DEFAULTS; + + static { + Map m = new LinkedHashMap<>(); + m.put("max_environments", 1); + m.put("max_apps", 3); + m.put("max_agents", 5); + m.put("max_users", 3); + m.put("max_outbound_connections", 1); + m.put("max_alert_rules", 2); + m.put("max_total_cpu_millis", 2000); + m.put("max_total_memory_mb", 2048); + m.put("max_total_replicas", 5); + m.put("max_execution_retention_days", 1); + m.put("max_log_retention_days", 1); + m.put("max_metric_retention_days", 1); + m.put("max_jar_retention_count", 3); + DEFAULTS = Collections.unmodifiableMap(m); + } + + private DefaultTierLimits() {} +} +``` + +- [ ] **Step 4.4: Create `LicenseLimits.java`** + +```java +package com.cameleer.server.core.license; + +import java.util.Map; +import java.util.Objects; + +public record LicenseLimits(Map values) { + + public LicenseLimits { + Objects.requireNonNull(values, "values"); + } + + public static LicenseLimits defaultsOnly() { + return new LicenseLimits(DefaultTierLimits.DEFAULTS); + } + + public static LicenseLimits mergeOverDefaults(Map overrides) { + java.util.Map merged = new java.util.LinkedHashMap<>(DefaultTierLimits.DEFAULTS); + if (overrides != null) merged.putAll(overrides); + return new LicenseLimits(java.util.Collections.unmodifiableMap(merged)); + } + + public int get(String key) { + Integer v = values.get(key); + if (v == null) { + throw new IllegalArgumentException("Unknown license limit key: " + key); + } + return v; + } + + public boolean isDefaultSourced(String key, LicenseInfo license) { + if (license == null) return true; + return !license.limits().containsKey(key); + } +} +``` + +- [ ] **Step 4.5: Create `LicenseState.java`** + +```java +package com.cameleer.server.core.license; + +public enum LicenseState { + ABSENT, + ACTIVE, + GRACE, + EXPIRED, + INVALID +} +``` + +- [ ] **Step 4.6: Write failing test — `LicenseStateMachineTest.java`** + +```java +package com.cameleer.server.core.license; + +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; + +class LicenseStateMachineTest { + + @Test + void noLicense_isAbsent() { + assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT); + } + + @Test + void invalidReason_isInvalid() { + assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID); + } + + @Test + void activeBeforeExp() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE); + } + + @Test + void graceWithinGracePeriod() { + LicenseInfo info = info(Instant.now().minusSeconds(86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE); + } + + @Test + void expiredAfterGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void expiredImmediatelyWithZeroGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(60), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void invalidWinsOverPresentLicense() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID); + } + + private LicenseInfo info(Instant exp, int graceDays) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(3600), exp, graceDays); + } +} +``` + +- [ ] **Step 4.7: Run — expect compile failure** + +Run: `mvn -pl cameleer-server-core test -Dtest=LicenseStateMachineTest` +Expected: FAIL — class missing. + +- [ ] **Step 4.8: Create `LicenseStateMachine.java`** + +```java +package com.cameleer.server.core.license; + +public final class LicenseStateMachine { + + private LicenseStateMachine() {} + + /** + * @param license parsed license, or null if no license is loaded + * @param invalidReason non-null if the last validation attempt failed + */ + public static LicenseState classify(LicenseInfo license, String invalidReason) { + if (invalidReason != null) { + return LicenseState.INVALID; + } + if (license == null) { + return LicenseState.ABSENT; + } + if (!license.isAfterRawExpiry()) { + return LicenseState.ACTIVE; + } + if (!license.isExpired()) { + return LicenseState.GRACE; + } + return LicenseState.EXPIRED; + } +} +``` + +- [ ] **Step 4.9: Run all core tests** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS — all four new tests + existing. + +- [ ] **Step 4.10: Commit** + +```bash +git add cameleer-server-core/src +git commit -m "$(cat <<'EOF' +feat(license): add LicenseLimits, DefaultTierLimits, LicenseStateMachine + +Pure-domain FSM (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID) and the +default-tier constants per spec §3. invalidReason wins over any +loaded license so signature failures surface as INVALID rather +than masking as ABSENT. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Rewrite `LicenseGate` with state, effective limits, and invalidReason + +**Why:** Spec §3.1 — gate must expose state + effective limits (merged over defaults) for the enforcer and usage reader. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` + +- [ ] **Step 5.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "LicenseGate", direction: "upstream"}) +``` + +- [ ] **Step 5.2: Write failing test — replace `LicenseGateTest.java` body** + +```java +package com.cameleer.server.core.license; + +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; + +class LicenseGateTest { + + @Test + void absent_byDefault() { + LicenseGate gate = new LicenseGate(); + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getCurrent()).isNull(); + assertThat(gate.getInvalidReason()).isNull(); + } + + @Test + void load_setsActiveAndMergesLimits() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", "label", + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + + assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE); + assertThat(gate.getEffectiveLimits().get("max_apps")).isEqualTo(50); + // unspecified key still inherits default + assertThat(gate.getEffectiveLimits().get("max_users")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_users")); + } + + @Test + void markInvalid_overridesActive() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + + gate.markInvalid("signature failed"); + assertThat(gate.getState()).isEqualTo(LicenseState.INVALID); + // limits revert to defaults when INVALID + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getInvalidReason()).isEqualTo("signature failed"); + } + + @Test + void clear_returnsToAbsent() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + gate.clear(); + + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getCurrent()).isNull(); + } +} +``` + +- [ ] **Step 5.3: Run — expect failures** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseGateTest` +Expected: FAIL — `getState`, `getEffectiveLimits`, `markInvalid`, `clear` missing. + +- [ ] **Step 5.4: Replace `LicenseGate.java`** + +```java +package com.cameleer.server.core.license; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicReference; + +public class LicenseGate { + + private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); + + private static final class Snapshot { + final LicenseInfo license; // null when ABSENT or INVALID + final String invalidReason; // null unless INVALID + Snapshot(LicenseInfo l, String r) { this.license = l; this.invalidReason = r; } + } + + private final AtomicReference snap = new AtomicReference<>(new Snapshot(null, null)); + + public void load(LicenseInfo license) { + snap.set(new Snapshot(license, null)); + log.info("License loaded: licenseId={}, tenantId={}, exp={}, gracePeriodDays={}", + license.licenseId(), license.tenantId(), license.expiresAt(), license.gracePeriodDays()); + } + + public void markInvalid(String reason) { + snap.set(new Snapshot(null, reason)); + log.error("License marked INVALID: {}", reason); + } + + public void clear() { + snap.set(new Snapshot(null, null)); + log.info("License cleared"); + } + + public LicenseInfo getCurrent() { + return snap.get().license; + } + + public String getInvalidReason() { + return snap.get().invalidReason; + } + + public LicenseState getState() { + Snapshot s = snap.get(); + return LicenseStateMachine.classify(s.license, s.invalidReason); + } + + /** Effective limits = defaults UNION license.limits, except in EXPIRED/ABSENT/INVALID where defaults win. */ + public LicenseLimits getEffectiveLimits() { + Snapshot s = snap.get(); + LicenseState state = LicenseStateMachine.classify(s.license, s.invalidReason); + if (state == LicenseState.ACTIVE || state == LicenseState.GRACE) { + return LicenseLimits.mergeOverDefaults(s.license.limits()); + } + return LicenseLimits.defaultsOnly(); + } + + public int getLimit(String key, int defaultValue) { + try { + return getEffectiveLimits().get(key); + } catch (IllegalArgumentException e) { + return defaultValue; + } + } +} +``` + +- [ ] **Step 5.5: Remove temporary `openSentinel()` from prior tasks (compile ripple)** + +Inside `LicenseGate.java` from Task 2.5, the temporary `openSentinel()` and field initialiser using it are now superseded by the new file in Step 5.4 — already replaced. Sanity-check by grepping: + +Run: `grep -r openSentinel cameleer-server-core/src cameleer-server-app/src || echo OK` +Expected: prints `OK`. + +- [ ] **Step 5.6: Run all core tests** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS. + +- [ ] **Step 5.7: Run app unit tests** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: PASS. + +- [ ] **Step 5.8: Commit** + +```bash +git add cameleer-server-core cameleer-server-app +git commit -m "$(cat <<'EOF' +feat(license): rewrite LicenseGate around state + effective limits + +LicenseGate now exposes getState() (delegates to LicenseStateMachine), +getEffectiveLimits() (merged over DefaultTierLimits in ACTIVE/GRACE, +defaults-only in ABSENT/EXPIRED/INVALID), markInvalid(reason), and +clear(). Internal snapshot is an immutable record swapped atomically +so concurrent reads see a consistent license+reason pair. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Create `cameleer-license-minter` Maven module + +**Why:** Spec §1.1 — vendor-only signing module; not on `cameleer-server-app`'s classpath. + +**Files:** +- Create: `cameleer-license-minter/pom.xml` +- Modify: `pom.xml` (root) — add module + +- [ ] **Step 6.1: Create the module directory and pom** + +```bash +mkdir -p cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli +mkdir -p cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli +``` + +- [ ] **Step 6.2: Write `cameleer-license-minter/pom.xml`** + +```xml + + + 4.0.0 + + + com.cameleer + cameleer-server-parent + 1.0-SNAPSHOT + + + cameleer-license-minter + Cameleer License Minter + Vendor-only Ed25519 license signing library + CLI + + + + com.cameleer + cameleer-server-core + + + com.fasterxml.jackson.core + jackson-databind + + + org.slf4j + slf4j-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + repackage-cli + + repackage + + + cli + com.cameleer.license.minter.cli.LicenseMinterCli + + + + + + + +``` + +- [ ] **Step 6.3: Register the module in root `pom.xml`** + +Edit `pom.xml`, locate the `` block, add the new entry: + +```xml + + cameleer-server-core + cameleer-server-app + cameleer-license-minter + +``` + +- [ ] **Step 6.4: Verify build** + +Run: `mvn -pl cameleer-license-minter -am compile` +Expected: BUILD SUCCESS — no Java sources yet, but module is recognised. + +- [ ] **Step 6.5: Commit** + +```bash +git add pom.xml cameleer-license-minter/pom.xml +git commit -m "$(cat <<'EOF' +feat(license-minter): add cameleer-license-minter Maven module + +Top-level module sibling to cameleer-server-core/-app. Depends on +cameleer-server-core for the LicenseInfo schema. Spring Boot +repackage produces a runnable -cli classifier for the vendor. + +Not added as a dependency from cameleer-server-app — runtime tree +must not carry signing primitives. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Implement `LicenseMinter` library + canonical-JSON serializer + +**Why:** Spec §7.1 — pure signer used by both the CLI and cameleer-saas. + +**Files:** +- Create: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java` +- Test: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java` + +- [ ] **Step 7.1: Write failing test** + +```java +package com.cameleer.license.minter; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseValidator; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseMinterTest { + + @Test + void roundTrip_validatorAcceptsMintedToken() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + String publicB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), "acme", "ACME prod", + Map.of("max_apps", 50, "max_agents", 100), + Instant.now(), Instant.now().plusSeconds(86400), 7); + + String token = LicenseMinter.mint(info, kp.getPrivate()); + + LicenseInfo parsed = new LicenseValidator(publicB64, "acme").validate(token); + assertThat(parsed.licenseId()).isEqualTo(info.licenseId()); + assertThat(parsed.tenantId()).isEqualTo("acme"); + assertThat(parsed.limits().get("max_apps")).isEqualTo(50); + assertThat(parsed.gracePeriodDays()).isEqualTo(7); + } + + @Test + void canonicalJson_isStableAcrossRuns() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + UUID id = UUID.randomUUID(); + Instant now = Instant.parse("2026-04-25T10:00:00Z"); + Instant exp = Instant.parse("2027-04-25T10:00:00Z"); + LicenseInfo info = new LicenseInfo(id, "acme", "label", + java.util.LinkedHashMap.newLinkedHashMap(2) + // intentional non-sorted insertion order + .ordered() + ? null : null, // placeholder; rebuild below + now, exp, 0); + // Build the map in non-alpha order and ensure canonical output is sorted + java.util.LinkedHashMap limits = new java.util.LinkedHashMap<>(); + limits.put("max_apps", 5); + limits.put("max_agents", 10); + LicenseInfo info2 = new LicenseInfo(id, "acme", "label", limits, now, exp, 0); + + String t1 = LicenseMinter.mint(info2, kp.getPrivate()); + String t2 = LicenseMinter.mint(info2, kp.getPrivate()); + assertThat(t1).isEqualTo(t2); + } +} +``` + +(Strip the bogus `LinkedHashMap.newLinkedHashMap` call — it's there only to fail. The real test below is `info2`.) + +Replace the entire test method body of `canonicalJson_isStableAcrossRuns` with just the `info2` construction and assertions: + +```java +@Test +void canonicalJson_isStableAcrossRuns() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + UUID id = UUID.randomUUID(); + Instant now = Instant.parse("2026-04-25T10:00:00Z"); + Instant exp = Instant.parse("2027-04-25T10:00:00Z"); + java.util.LinkedHashMap limits = new java.util.LinkedHashMap<>(); + limits.put("max_apps", 5); + limits.put("max_agents", 10); + LicenseInfo info = new LicenseInfo(id, "acme", "label", limits, now, exp, 0); + + String t1 = LicenseMinter.mint(info, kp.getPrivate()); + String t2 = LicenseMinter.mint(info, kp.getPrivate()); + assertThat(t1).isEqualTo(t2); +} +``` + +- [ ] **Step 7.2: Run — expect compile failure** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterTest` +Expected: FAIL — class missing. + +- [ ] **Step 7.3: Implement `LicenseMinter.java`** + +```java +package com.cameleer.license.minter; + +import com.cameleer.server.core.license.LicenseInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.security.PrivateKey; +import java.security.Signature; +import java.util.Base64; +import java.util.TreeMap; + +public final class LicenseMinter { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + + private LicenseMinter() {} + + public static String mint(LicenseInfo info, PrivateKey ed25519PrivateKey) { + byte[] payload = canonicalPayload(info); + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519PrivateKey); + signer.update(payload); + byte[] sig = signer.sign(); + return Base64.getEncoder().encodeToString(payload) + "." + Base64.getEncoder().encodeToString(sig); + } catch (Exception e) { + throw new IllegalStateException("Failed to sign license", e); + } + } + + static byte[] canonicalPayload(LicenseInfo info) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("exp", info.expiresAt().getEpochSecond()); + root.put("gracePeriodDays", info.gracePeriodDays()); + root.put("iat", info.issuedAt().getEpochSecond()); + if (info.label() != null) { + root.put("label", info.label()); + } + root.put("licenseId", info.licenseId().toString()); + ObjectNode limits = MAPPER.createObjectNode(); + new TreeMap<>(info.limits()).forEach(limits::put); + root.set("limits", limits); + root.put("tenantId", info.tenantId()); + try { + return MAPPER.writeValueAsBytes(root); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize license payload", e); + } + } +} +``` + +- [ ] **Step 7.4: Run tests** + +Run: `mvn -pl cameleer-license-minter test` +Expected: PASS — both tests. + +- [ ] **Step 7.5: Commit** + +```bash +git add cameleer-license-minter +git commit -m "$(cat <<'EOF' +feat(license-minter): implement LicenseMinter library + +Pure signing primitive: serialises LicenseInfo to canonical JSON +(sorted top-level keys via ORDER_MAP_ENTRIES_BY_KEYS plus a TreeMap +for the limits sub-object) then signs with Ed25519. Round-trips +through LicenseValidator and is byte-stable across runs for +identical inputs. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Implement `LicenseMinterCli` (without `--verify` yet) + +**Why:** Spec §7.2 — vendor CLI. Split from `--verify` (Task 9) to keep diffs reviewable. + +**Files:** +- Create: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` +- Test: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` + +- [ ] **Step 8.1: Write failing test** + +```java +package com.cameleer.license.minter.cli; + +import com.cameleer.server.core.license.LicenseValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseMinterCliTest { + + @TempDir Path tmp; + + @Test + void mints_validToken_validatorAccepts() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Path priv = tmp.resolve("priv.b64"); + Path pub = tmp.resolve("pub.b64"); + Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); + Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded())); + Path out = tmp.resolve("license.tok"); + + int code = LicenseMinterCli.run(new String[]{ + "--private-key=" + priv, + "--tenant=acme", + "--label=ACME", + "--expires=2099-12-31", + "--grace-days=30", + "--max-apps=50", + "--output=" + out + }); + + assertThat(code).isEqualTo(0); + String token = Files.readString(out).trim(); + var info = new LicenseValidator(Files.readString(pub).trim(), "acme").validate(token); + assertThat(info.tenantId()).isEqualTo("acme"); + assertThat(info.limits().get("max_apps")).isEqualTo(50); + assertThat(info.gracePeriodDays()).isEqualTo(30); + } + + @Test + void unknownFlag_failsFast() { + int code = LicenseMinterCli.run(new String[]{"--frobnicate=yes"}); + assertThat(code).isNotZero(); + } +} +``` + +- [ ] **Step 8.2: Run — expect failure** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` +Expected: FAIL — class missing. + +- [ ] **Step 8.3: Implement `LicenseMinterCli.java`** + +```java +package com.cameleer.license.minter.cli; + +import com.cameleer.license.minter.LicenseMinter; +import com.cameleer.server.core.license.LicenseInfo; + +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.*; + +public final class LicenseMinterCli { + + private static final Set KNOWN_FLAGS = Set.of( + "--private-key", "--public-key", "--tenant", "--label", + "--expires", "--grace-days", "--output", "--verify" + ); + + public static void main(String[] args) { + System.exit(run(args)); + } + + public static int run(String[] args) { + return run(args, System.out, System.err); + } + + public static int run(String[] args, PrintStream out, PrintStream err) { + Map flags = new LinkedHashMap<>(); + Set bool = new HashSet<>(); + Map limits = new TreeMap<>(); + for (String arg : args) { + if (!arg.startsWith("--")) { + err.println("unexpected positional argument: " + arg); + return 2; + } + int eq = arg.indexOf('='); + String key = eq < 0 ? arg : arg.substring(0, eq); + String value = eq < 0 ? null : arg.substring(eq + 1); + if (key.startsWith("--max-")) { + String limitKey = "max_" + key.substring("--max-".length()).replace('-', '_'); + if (value == null) { + err.println("missing value for " + key); + return 2; + } + limits.put(limitKey, Integer.parseInt(value)); + continue; + } + if (!KNOWN_FLAGS.contains(key)) { + err.println("unknown flag: " + key); + return 2; + } + if (value == null) { + bool.add(key); + } else { + flags.put(key, value); + } + } + + String privPath = flags.get("--private-key"); + String tenant = flags.get("--tenant"); + String expiresIso = flags.get("--expires"); + if (privPath == null || tenant == null || expiresIso == null) { + err.println("required: --private-key --tenant --expires"); + return 2; + } + + try { + PrivateKey privateKey = readEd25519PrivateKey(Path.of(privPath)); + int graceDays = Integer.parseInt(flags.getOrDefault("--grace-days", "0")); + Instant exp = LocalDate.parse(expiresIso).atStartOfDay(ZoneOffset.UTC).toInstant(); + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), + tenant, + flags.get("--label"), + Collections.unmodifiableMap(limits), + Instant.now(), + exp, + graceDays + ); + String token = LicenseMinter.mint(info, privateKey); + + String outPath = flags.get("--output"); + if (outPath != null) { + Files.writeString(Path.of(outPath), token); + out.println("wrote " + outPath); + } else { + out.println(token); + } + return 0; + } catch (Exception e) { + err.println("ERROR: " + e.getMessage()); + return 1; + } + } + + private static PrivateKey readEd25519PrivateKey(Path path) throws Exception { + String s = Files.readString(path).trim(); + // Accept base64 of PKCS#8 (openssl pkey -outform DER + base64), or PEM + if (s.startsWith("-----BEGIN")) { + s = s.replaceAll("-----BEGIN [A-Z ]+-----", "") + .replaceAll("-----END [A-Z ]+-----", "") + .replaceAll("\\s", ""); + } + byte[] der = Base64.getDecoder().decode(s); + return KeyFactory.getInstance("Ed25519") + .generatePrivate(new PKCS8EncodedKeySpec(der)); + } +} +``` + +- [ ] **Step 8.4: Run tests** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` +Expected: PASS — both tests. + +- [ ] **Step 8.5: Commit** + +```bash +git add cameleer-license-minter +git commit -m "$(cat <<'EOF' +feat(license-minter): add LicenseMinterCli (without --verify) + +Reads PEM or base64 PKCS#8 Ed25519 private key, maps --max-foo-bar +flags to max_foo_bar limit keys, parses --expires as a UTC date, +defaults --grace-days to 0. Unknown flags fail fast with exit 2. +--verify path is added in the next task. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Add `--verify` round-trip to `LicenseMinterCli` + +**Why:** Spec §7.2 — verify the freshly-minted token before shipping it to the customer. Delete the output file on failure. + +**Files:** +- Modify: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` +- Modify: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` + +- [ ] **Step 9.1: Add failing tests** + +Append to `LicenseMinterCliTest.java`: + +```java +@Test +void verify_happyPath_succeeds() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Path priv = tmp.resolve("priv.b64"); + Path pub = tmp.resolve("pub.b64"); + Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); + Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded())); + Path out = tmp.resolve("license.tok"); + + int code = LicenseMinterCli.run(new String[]{ + "--private-key=" + priv, + "--public-key=" + pub, + "--tenant=acme", + "--expires=2099-12-31", + "--output=" + out, + "--verify" + }); + + assertThat(code).isEqualTo(0); + assertThat(out).exists(); +} + +@Test +void verify_wrongPublicKey_deletesOutputAndExitsNonZero() throws Exception { + KeyPair signing = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Path priv = tmp.resolve("priv.b64"); + Path pub = tmp.resolve("pub.b64"); + Files.writeString(priv, Base64.getEncoder().encodeToString(signing.getPrivate().getEncoded())); + Files.writeString(pub, Base64.getEncoder().encodeToString(other.getPublic().getEncoded())); + Path out = tmp.resolve("license.tok"); + + int code = LicenseMinterCli.run(new String[]{ + "--private-key=" + priv, + "--public-key=" + pub, + "--tenant=acme", + "--expires=2099-12-31", + "--output=" + out, + "--verify" + }); + + assertThat(code).isNotZero(); + assertThat(out).doesNotExist(); +} + +@Test +void verify_withoutPublicKey_fails() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + Path priv = tmp.resolve("priv.b64"); + Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); + + int code = LicenseMinterCli.run(new String[]{ + "--private-key=" + priv, + "--tenant=acme", + "--expires=2099-12-31", + "--verify" + }); + + assertThat(code).isNotZero(); +} +``` + +- [ ] **Step 9.2: Run — expect failures** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` +Expected: FAIL — `--verify` is silently accepted but does nothing. + +- [ ] **Step 9.3: Implement `--verify`** + +In `LicenseMinterCli.run(...)`, after computing `token` and writing the output file (if any), insert before `return 0;`: + +```java +if (bool.contains("--verify")) { + String pubPath = flags.get("--public-key"); + if (pubPath == null) { + err.println("--verify requires --public-key"); + if (outPath != null) Files.deleteIfExists(Path.of(outPath)); + return 2; + } + try { + String pubB64 = Files.readString(Path.of(pubPath)).trim(); + new com.cameleer.server.core.license.LicenseValidator(pubB64, tenant).validate(token); + out.println("verified ok"); + } catch (Exception ve) { + err.println("VERIFY FAILED: " + ve.getMessage()); + if (outPath != null) Files.deleteIfExists(Path.of(outPath)); + return 3; + } +} +``` + +- [ ] **Step 9.4: Run tests** + +Run: `mvn -pl cameleer-license-minter test` +Expected: PASS — all five. + +- [ ] **Step 9.5: Commit** + +```bash +git add cameleer-license-minter +git commit -m "$(cat <<'EOF' +feat(license-minter): --verify round-trips before shipping + +Adds --verify (requires --public-key) to LicenseMinterCli. After +writing the output file the CLI parses the freshly-minted token +through LicenseValidator against the supplied public key. On +verify failure the output file is deleted (so the bad token is +not accidentally shipped) and the CLI exits 3. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Flyway V5 — `license` table + `environments` retention columns + +**Why:** Spec §6.1 + §4.2. + +**Files:** +- Create: `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` + +- [ ] **Step 10.1: Write migration** + +```sql +-- Per-tenant license row (one server = one tenant) +CREATE TABLE license ( + tenant_id TEXT PRIMARY KEY, + token TEXT NOT NULL, + license_id UUID NOT NULL, + installed_at TIMESTAMPTZ NOT NULL, + installed_by TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + last_validated_at TIMESTAMPTZ NOT NULL +); + +-- Per-env retention; defaults to default-tier values (1 day) so a fresh +-- server lands inside the cap without operator intervention. +ALTER TABLE environments + ADD COLUMN execution_retention_days INTEGER NOT NULL DEFAULT 1, + ADD COLUMN log_retention_days INTEGER NOT NULL DEFAULT 1, + ADD COLUMN metric_retention_days INTEGER NOT NULL DEFAULT 1; +``` + +- [ ] **Step 10.2: Verify migration applies cleanly via SchemaBootstrapIT (will be extended in Task 35; for now smoke via app boot)** + +Run: `mvn -pl cameleer-server-app test -Dtest=SchemaBootstrapIT` +Expected: PASS — existing assertions still hold; the new columns/table appear silently. + +- [ ] **Step 10.3: Commit** + +```bash +git add cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql +git commit -m "$(cat <<'EOF' +feat(license): Flyway V5 — license table + environments retention columns + +Per-tenant license row stores the signed token, licenseId for audit, +installed/expires/last_validated timestamps. environments gains three +INTEGER NOT NULL DEFAULT 1 retention columns (execution, log, metric) +so existing rows land inside the default-tier cap. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: `LicenseRecord` + `LicenseRepository` + `PostgresLicenseRepository` + +**Why:** Spec §6.1, §6.2 — typed access to the `license` row. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java` + +- [ ] **Step 11.1: Create the record + interface** + +`LicenseRecord.java`: + +```java +package com.cameleer.server.app.license; + +import java.time.Instant; +import java.util.UUID; + +public record LicenseRecord( + String tenantId, + String token, + UUID licenseId, + Instant installedAt, + String installedBy, + Instant expiresAt, + Instant lastValidatedAt +) {} +``` + +`LicenseRepository.java`: + +```java +package com.cameleer.server.app.license; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +public interface LicenseRepository { + Optional findByTenantId(String tenantId); + + /** Insert or replace the row for tenantId. */ + void upsert(LicenseRecord record); + + /** Update last_validated_at to `now` and return rows affected (0 = no row). */ + int touchValidated(String tenantId, Instant now); + + /** Delete the row (used when the operator clears a license; not a public API in v1). */ + int delete(String tenantId); +} +``` + +- [ ] **Step 11.2: Write integration test (Testcontainers Postgres)** + +```java +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 java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostgresLicenseRepositoryIT extends AbstractPostgresIT { + + @Autowired LicenseRepository repo; + + @Test + void roundTrip() { + UUID id = UUID.randomUUID(); + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + LicenseRecord rec = new LicenseRecord( + "default", "tok.sig", id, now, "system", + now.plus(365, ChronoUnit.DAYS), now); + repo.upsert(rec); + + var loaded = repo.findByTenantId("default").orElseThrow(); + assertThat(loaded.licenseId()).isEqualTo(id); + assertThat(loaded.installedBy()).isEqualTo("system"); + } + + @Test + void touchValidated_updatesTimestamp() throws Exception { + UUID id = UUID.randomUUID(); + Instant t0 = Instant.now().truncatedTo(ChronoUnit.MILLIS); + repo.upsert(new LicenseRecord("default", "tok.sig", id, t0, "system", + t0.plus(7, ChronoUnit.DAYS), t0)); + + Thread.sleep(10); + Instant t1 = Instant.now().truncatedTo(ChronoUnit.MILLIS); + int affected = repo.touchValidated("default", t1); + assertThat(affected).isEqualTo(1); + assertThat(repo.findByTenantId("default").orElseThrow().lastValidatedAt()) + .isAfterOrEqualTo(t1); + } +} +``` + +- [ ] **Step 11.3: Run — expect compile failure (impl missing)** + +Run: `mvn -pl cameleer-server-app test -Dtest=PostgresLicenseRepositoryIT` +Expected: FAIL — `PostgresLicenseRepository` class missing. + +- [ ] **Step 11.4: Implement `PostgresLicenseRepository.java`** + +```java +package com.cameleer.server.app.license; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +public class PostgresLicenseRepository implements LicenseRepository { + + private final JdbcTemplate jdbc; + + public PostgresLicenseRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + private static final RowMapper MAPPER = (rs, n) -> new LicenseRecord( + rs.getString("tenant_id"), + rs.getString("token"), + (UUID) rs.getObject("license_id"), + rs.getTimestamp("installed_at").toInstant(), + rs.getString("installed_by"), + rs.getTimestamp("expires_at").toInstant(), + rs.getTimestamp("last_validated_at").toInstant() + ); + + @Override + public Optional findByTenantId(String tenantId) { + return jdbc.query( + "SELECT tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at " + + "FROM license WHERE tenant_id = ?", + MAPPER, tenantId).stream().findFirst(); + } + + @Override + public void upsert(LicenseRecord r) { + jdbc.update( + "INSERT INTO license (tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (tenant_id) DO UPDATE SET " + + " token = EXCLUDED.token, " + + " license_id = EXCLUDED.license_id, " + + " installed_at = EXCLUDED.installed_at, " + + " installed_by = EXCLUDED.installed_by, " + + " expires_at = EXCLUDED.expires_at, " + + " last_validated_at = EXCLUDED.last_validated_at", + r.tenantId(), r.token(), r.licenseId(), + Timestamp.from(r.installedAt()), r.installedBy(), + Timestamp.from(r.expiresAt()), Timestamp.from(r.lastValidatedAt()) + ); + } + + @Override + public int touchValidated(String tenantId, Instant now) { + return jdbc.update( + "UPDATE license SET last_validated_at = ? WHERE tenant_id = ?", + Timestamp.from(now), tenantId); + } + + @Override + public int delete(String tenantId) { + return jdbc.update("DELETE FROM license WHERE tenant_id = ?", tenantId); + } +} +``` + +- [ ] **Step 11.5: Wire the bean (temporary placement; Task 14 consolidates)** + +In `cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java`, add: + +```java +@Bean +public com.cameleer.server.app.license.LicenseRepository licenseRepository( + org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) { + return new com.cameleer.server.app.license.PostgresLicenseRepository(jdbcTemplate); +} +``` + +- [ ] **Step 11.6: Run IT** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=PostgresLicenseRepositoryIT` +Expected: PASS, 2/2. + +- [ ] **Step 11.7: Commit** + +```bash +git add cameleer-server-app/src/{main,test}/java/com/cameleer/server/app/license cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java +git commit -m "$(cat <<'EOF' +feat(license): PostgresLicenseRepository + LicenseRecord + +JdbcTemplate-backed repo; upsert is ON CONFLICT (tenant_id), touch +updates only last_validated_at, delete is provided for future +operator-clear flow (not exposed as REST in v1). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: `AuditCategory.LICENSE` + +**Why:** Spec §6.5 — every license install/replace/reject and every cap rejection writes an audit row under a dedicated category. + +**Files:** +- Modify: the file that declares `AuditCategory` (find via `grep -rn "enum AuditCategory" cameleer-server-core/src cameleer-server-app/src`) + +- [ ] **Step 12.1: Locate the enum** + +Run: `grep -rn "enum AuditCategory" cameleer-server-core/src cameleer-server-app/src` +Note the file path; expected location is core (per `.claude/rules/core-classes.md`). + +- [ ] **Step 12.2: Add `LICENSE` to the enum** + +```java +public enum AuditCategory { + INFRA, + AUTH, + USER_MGMT, + CONFIG, + RBAC, + AGENT, + OUTBOUND_CONNECTION_CHANGE, + OUTBOUND_HTTP_TRUST_CHANGE, + ALERT_RULE_CHANGE, + ALERT_SILENCE_CHANGE, + DEPLOYMENT, + LICENSE +} +``` + +(Insert `LICENSE` last to avoid renumbering existing values if the enum is persisted by ordinal — verify quickly: `grep -n "ordinal\|name()" $(grep -rl "AuditCategory" cameleer-server-app/src) | head`. The category column is stored by `name()` in `audit_log`; appending is safe.) + +- [ ] **Step 12.3: Build** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS. + +- [ ] **Step 12.4: Commit** + +```bash +git add -u +git commit -m "$(cat <<'EOF' +feat(license): add AuditCategory.LICENSE + +Tasks downstream (LicenseService, LicenseEnforcer) audit under +this category for install_license / replace_license / reject_license +/ revalidate_license / cap_exceeded actions. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: `LicenseChangedEvent` + `LicenseService` + +**Why:** Spec §6.3 — single service that mediates DB ↔ gate, publishes events on every change, and audits. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java` + +- [ ] **Step 13.1: Create the event** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseState; + +import java.util.Objects; + +public record LicenseChangedEvent(LicenseState state, LicenseInfo current) { + public LicenseChangedEvent { + Objects.requireNonNull(state); + } +} +``` + +- [ ] **Step 13.2: Write failing test** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.admin.AuditCategory; +import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.license.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class LicenseServiceTest { + + LicenseRepository repo; + LicenseGate gate; + AuditService audit; + ApplicationEventPublisher events; + LicenseValidator validator; + LicenseService svc; + + @BeforeEach + void setUp() { + repo = mock(LicenseRepository.class); + gate = new LicenseGate(); + audit = mock(AuditService.class); + events = mock(ApplicationEventPublisher.class); + validator = mock(LicenseValidator.class); + svc = new LicenseService("default", repo, gate, validator, audit, events); + } + + @Test + void install_validToken_persistsAndPublishes() { + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "default", null, + Map.of("max_apps", 5), Instant.now(), + Instant.now().plusSeconds(86400), 0); + when(validator.validate("tok")).thenReturn(info); + when(repo.findByTenantId("default")).thenReturn(Optional.empty()); + + svc.install("tok", "alice", "api"); + + assertThat(gate.getCurrent()).isEqualTo(info); + assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE); + verify(repo).upsert(any(LicenseRecord.class)); + verify(events).publishEvent(any(LicenseChangedEvent.class)); + verify(audit).record(eq(AuditCategory.LICENSE), eq("install_license"), any(), any()); + } + + @Test + void install_invalidToken_marksGateInvalidAndAudits() { + when(validator.validate("bad")).thenThrow(new SecurityException("signature failed")); + + try { + svc.install("bad", "alice", "api"); + } catch (Exception ignored) {} + + assertThat(gate.getState()).isEqualTo(LicenseState.INVALID); + assertThat(gate.getInvalidReason()).contains("signature failed"); + verify(repo, never()).upsert(any()); + verify(audit).record(eq(AuditCategory.LICENSE), eq("reject_license"), any(), any()); + } + + @Test + void install_replacingExistingLicense_auditsReplace() { + LicenseInfo old = new LicenseInfo(UUID.randomUUID(), "default", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(old); + when(repo.findByTenantId("default")).thenReturn(Optional.of( + new LicenseRecord("default", "old", old.licenseId(), + Instant.now(), "system", + Instant.now().plusSeconds(86400), Instant.now()))); + LicenseInfo fresh = new LicenseInfo(UUID.randomUUID(), "default", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + when(validator.validate("new")).thenReturn(fresh); + + svc.install("new", "alice", "api"); + + verify(audit).record(eq(AuditCategory.LICENSE), eq("replace_license"), any(), any()); + } +} +``` + +- [ ] **Step 13.3: Run — expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseServiceTest` +Expected: FAIL — class missing. + +- [ ] **Step 13.4: Implement `LicenseService.java`** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.admin.AuditCategory; +import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class LicenseService { + + private static final Logger log = LoggerFactory.getLogger(LicenseService.class); + + private final String tenantId; + private final LicenseRepository repo; + private final LicenseGate gate; + private final LicenseValidator validator; + private final AuditService audit; + private final ApplicationEventPublisher events; + + public LicenseService(String tenantId, LicenseRepository repo, LicenseGate gate, + LicenseValidator validator, AuditService audit, + ApplicationEventPublisher events) { + this.tenantId = tenantId; + this.repo = repo; + this.gate = gate; + this.validator = validator; + this.audit = audit; + this.events = events; + } + + /** Install a token from any source (env, file, api). source ∈ {env,file,api,db}. */ + public LicenseInfo install(String token, String installedBy, String source) { + LicenseInfo info; + try { + info = validator.validate(token); + } catch (Exception e) { + String reason = e.getMessage(); + gate.markInvalid(reason); + Map payload = new LinkedHashMap<>(); + payload.put("reason", reason); + payload.put("source", source); + audit.record(AuditCategory.LICENSE, "reject_license", installedBy, payload); + events.publishEvent(new LicenseChangedEvent(gate.getState(), gate.getCurrent())); + throw e instanceof RuntimeException re ? re : new IllegalArgumentException(e); + } + + Optional existing = repo.findByTenantId(tenantId); + Instant now = Instant.now(); + repo.upsert(new LicenseRecord( + tenantId, token, info.licenseId(), + now, installedBy, info.expiresAt(), now)); + gate.load(info); + + Map payload = new LinkedHashMap<>(); + payload.put("licenseId", info.licenseId().toString()); + payload.put("expiresAt", info.expiresAt().toString()); + payload.put("installedBy", installedBy); + payload.put("source", source); + if (existing.isPresent()) { + payload.put("previousLicenseId", existing.get().licenseId().toString()); + audit.record(AuditCategory.LICENSE, "replace_license", installedBy, payload); + } else { + audit.record(AuditCategory.LICENSE, "install_license", installedBy, payload); + } + + events.publishEvent(new LicenseChangedEvent(gate.getState(), info)); + return info; + } + + /** Boot-time load: prefer env/file overrides; falls back to DB; ABSENT if none. */ + public void loadInitial(Optional envToken, Optional fileToken) { + if (envToken.isPresent()) { + try { install(envToken.get(), "system", "env"); return; } + catch (Exception e) { log.error("env-var license rejected: {}", e.getMessage()); } + } + if (fileToken.isPresent()) { + try { install(fileToken.get(), "system", "file"); return; } + catch (Exception e) { log.error("file license rejected: {}", e.getMessage()); } + } + Optional persisted = repo.findByTenantId(tenantId); + if (persisted.isPresent()) { + try { install(persisted.get().token(), persisted.get().installedBy(), "db"); } + catch (Exception e) { log.error("DB license rejected: {}", e.getMessage()); } + } else { + log.info("No license configured — running in default tier"); + events.publishEvent(new LicenseChangedEvent(gate.getState(), null)); + } + } + + /** Re-run validation against the persisted token (daily job). */ + public void revalidate() { + Optional persisted = repo.findByTenantId(tenantId); + if (persisted.isEmpty()) return; + try { + LicenseInfo info = validator.validate(persisted.get().token()); + repo.touchValidated(tenantId, Instant.now()); + // Promote into gate in case it was marked INVALID for a transient reason + gate.load(info); + events.publishEvent(new LicenseChangedEvent(gate.getState(), info)); + } catch (Exception e) { + String reason = e.getMessage(); + gate.markInvalid(reason); + Map payload = new LinkedHashMap<>(); + payload.put("licenseId", persisted.get().licenseId().toString()); + payload.put("reason", reason); + audit.record(AuditCategory.LICENSE, "revalidate_license", "system", payload); + events.publishEvent(new LicenseChangedEvent(gate.getState(), null)); + log.error("Revalidation failed: {}", reason); + } + } + + public String getTenantId() { return tenantId; } +} +``` + +If the existing `AuditService.record(...)` signature differs, adapt the call sites in this class to match. Run `grep -n "void record" $(grep -rl "interface AuditService" cameleer-server-core/src)` to confirm. + +- [ ] **Step 13.5: Run unit test** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseServiceTest` +Expected: PASS, 3/3. + +- [ ] **Step 13.6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): LicenseService + LicenseChangedEvent + +Single mediation point for token install/replace/revalidate. Audits +under AuditCategory.LICENSE, persists to PG, mutates the LicenseGate, +and publishes LicenseChangedEvent so downstream listeners +(RetentionPolicyApplier, LicenseMetrics) react uniformly. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 14: Refactor `LicenseBeanConfig` boot order; wire `LicenseService` + +**Why:** Spec §6.2 — env > file > DB; emit one `LicenseChangedEvent` on boot. + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` + +- [ ] **Step 14.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "LicenseBeanConfig", direction: "upstream"}) +``` + +- [ ] **Step 14.2: Replace `LicenseBeanConfig.java`** + +```java +package com.cameleer.server.app.config; + +import com.cameleer.server.app.license.LicenseRepository; +import com.cameleer.server.app.license.LicenseService; +import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseValidator; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +@Configuration +public class LicenseBeanConfig { + + private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class); + + @Value("${cameleer.server.tenant.id:default}") + private String tenantId; + + @Value("${cameleer.server.license.token:}") + private String licenseToken; + + @Value("${cameleer.server.license.file:}") + private String licenseFile; + + @Value("${cameleer.server.license.publickey:}") + private String licensePublicKey; + + @Bean + public LicenseGate licenseGate() { + return new LicenseGate(); + } + + @Bean + public LicenseValidator licenseValidator() { + if (licensePublicKey == null || licensePublicKey.isBlank()) { + log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID"); + // A non-functional validator that always throws so install() routes to INVALID. + return new LicenseValidator( + "MCowBQYDK2VwAyEA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + tenantId) { + @Override public com.cameleer.server.core.license.LicenseInfo validate(String token) { + throw new IllegalStateException("license public key not configured"); + } + }; + } + return new LicenseValidator(licensePublicKey, tenantId); + } + + @Bean + public LicenseService licenseService(LicenseRepository repo, LicenseGate gate, + LicenseValidator validator, + AuditService audit, + ApplicationEventPublisher events) { + return new LicenseService(tenantId, repo, gate, validator, audit, events); + } + + @Bean + public LicenseBootLoader licenseBootLoader(LicenseService svc) { + return new LicenseBootLoader(svc, licenseToken, licenseFile); + } + + public static class LicenseBootLoader { + private final LicenseService svc; + private final String envToken; + private final String filePath; + + public LicenseBootLoader(LicenseService svc, String envToken, String filePath) { + this.svc = svc; + this.envToken = envToken; + this.filePath = filePath; + } + + @PostConstruct + public void load() { + Optional env = (envToken != null && !envToken.isBlank()) + ? Optional.of(envToken) : Optional.empty(); + Optional file = Optional.empty(); + if (filePath != null && !filePath.isBlank()) { + try { + file = Optional.of(Files.readString(Path.of(filePath)).trim()); + } catch (Exception e) { + log.warn("Failed to read license file {}: {}", filePath, e.getMessage()); + } + } + svc.loadInitial(env, file); + } + } +} +``` + +The anonymous-subclass trick for the absent-key case avoids surfacing a separate "no validator" bean type; `install()` will catch its exception and route to INVALID. If you prefer cleaner architecture, introduce a `LicenseValidator.alwaysFails(reason)` static factory in core — but that's a follow-up. + +- [ ] **Step 14.3: Build** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: PASS — existing tests still green. + +- [ ] **Step 14.4: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java +git commit -m "$(cat <<'EOF' +feat(license): wire LicenseService into boot order (env > file > DB) + +LicenseBootLoader @PostConstruct calls LicenseService.loadInitial, +which delegates to install() so env-var/file/DB paths share a single +audit + event-publish code path. A missing public key now produces +an always-failing validator so loaded tokens route to INVALID +instead of being silently ignored. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 15: `LicenseCapExceededException`, `LicenseMessageRenderer`, `LicenseExceptionAdvice` + +**Why:** Spec §4 — typed exception + per-state rendered message + global 403 mapping. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java` + +- [ ] **Step 15.1: Create the exception** + +```java +package com.cameleer.server.app.license; + +public class LicenseCapExceededException extends RuntimeException { + private final String limitKey; + private final long current; + private final long cap; + + public LicenseCapExceededException(String limitKey, long current, long cap) { + super("license cap reached: " + limitKey + " current=" + current + " cap=" + cap); + this.limitKey = limitKey; + this.current = current; + this.cap = cap; + } + + public String limitKey() { return limitKey; } + public long current() { return current; } + public long cap() { return cap; } +} +``` + +- [ ] **Step 15.2: Write failing test for `LicenseMessageRenderer`** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseState; +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; + +class LicenseMessageRendererTest { + + @Test + void absent_message() { + var msg = LicenseMessageRenderer.forCap(LicenseState.ABSENT, null, "max_apps", 0, 3); + assertThat(msg).contains("No license").contains("max_apps").contains("3"); + } + + @Test + void active_message() { + LicenseInfo info = info(Instant.now().plusSeconds(86400 * 100), 0); + var msg = LicenseMessageRenderer.forCap(LicenseState.ACTIVE, info, "max_apps", 50, 50); + assertThat(msg).contains("cap reached").contains("50"); + } + + @Test + void grace_message_includesDayCount() { + LicenseInfo info = info(Instant.now().minusSeconds(86400L * 5), 30); + var msg = LicenseMessageRenderer.forCap(LicenseState.GRACE, info, "max_apps", 10, 10); + assertThat(msg).contains("expired").contains("5").contains("grace"); + } + + @Test + void expired_message_explainsRevert() { + LicenseInfo info = info(Instant.now().minusSeconds(86400L * 60), 30); + var msg = LicenseMessageRenderer.forCap(LicenseState.EXPIRED, info, "max_apps", 40, 3); + assertThat(msg).contains("expired").contains("default tier").contains("3"); + } + + @Test + void invalid_message_includesReason() { + var msg = LicenseMessageRenderer.forCap(LicenseState.INVALID, null, + "max_apps", 0, 3, "signature failed"); + assertThat(msg).contains("rejected").contains("signature failed"); + } + + private LicenseInfo info(Instant exp, int graceDays) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(86400L * 365), exp, graceDays); + } +} +``` + +- [ ] **Step 15.3: Run — expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseMessageRendererTest` +Expected: FAIL. + +- [ ] **Step 15.4: Implement `LicenseMessageRenderer.java`** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseState; + +import java.time.Duration; +import java.time.Instant; + +public final class LicenseMessageRenderer { + + private LicenseMessageRenderer() {} + + public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap) { + return forCap(state, info, limit, current, cap, null); + } + + public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap, String invalidReason) { + switch (state) { + case ABSENT: + return "No license installed: default tier applies (cap = " + cap + " for " + limit + + "). Install a license to raise this."; + case ACTIVE: + return "License cap reached: " + limit + " = " + cap + ". Current usage is " + current + + ". Contact your vendor to raise the cap."; + case GRACE: { + long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); + long graceRemaining = info == null ? 0 + : Math.max(0, info.gracePeriodDays() - expiredDaysAgo); + return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period " + + "(ends in " + graceRemaining + " days). Cap unchanged at " + cap + + ". Renew before grace ends."; + } + case EXPIRED: { + long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); + return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier (cap = " + + cap + " for " + limit + "). Current usage is " + current + + ". Renew the license to lift the cap."; + } + case INVALID: + return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason) + + "): default tier applies (cap = " + cap + " for " + limit + "). Fix the license to raise this."; + default: + return "License cap reached: " + limit + " = " + cap; + } + } + + private static long daysSince(Instant t) { + return Math.max(0, Duration.between(t, Instant.now()).toDays()); + } +} +``` + +- [ ] **Step 15.5: Run renderer test** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseMessageRendererTest` +Expected: PASS, 5/5. + +- [ ] **Step 15.6: Implement `LicenseExceptionAdvice.java`** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseInfo; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.LinkedHashMap; +import java.util.Map; + +@ControllerAdvice +public class LicenseExceptionAdvice { + + private final LicenseGate gate; + + public LicenseExceptionAdvice(LicenseGate gate) { + this.gate = gate; + } + + @ExceptionHandler(LicenseCapExceededException.class) + public ResponseEntity> handle(LicenseCapExceededException e) { + var state = gate.getState(); + LicenseInfo info = gate.getCurrent(); + String reason = gate.getInvalidReason(); + Map body = new LinkedHashMap<>(); + body.put("error", "license cap reached"); + body.put("limit", e.limitKey()); + body.put("current", e.current()); + body.put("cap", e.cap()); + body.put("state", state.name()); + body.put("message", LicenseMessageRenderer.forCap(state, info, e.limitKey(), e.current(), e.cap(), reason)); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body); + } +} +``` + +- [ ] **Step 15.7: Build** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: PASS. + +- [ ] **Step 15.8: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): cap-exceeded exception + state-aware message renderer + +LicenseCapExceededException + @ControllerAdvice mapping to 403 with a +body that includes state, limit, current, cap, and a per-state human +message templated by LicenseMessageRenderer (covers ABSENT/ACTIVE/ +GRACE/EXPIRED/INVALID with day counts and reason). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 16: `LicenseEnforcer` + +**Why:** Spec §4 — single entry point for cap checks. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java` + +- [ ] **Step 16.1: Write failing test** + +```java +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); + } +} +``` + +- [ ] **Step 16.2: Run — expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseEnforcerTest` +Expected: FAIL. + +- [ ] **Step 16.3: Implement `LicenseEnforcer.java`** + +```java +package com.cameleer.server.app.license; + +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 org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Component +public class LicenseEnforcer { + + private final LicenseGate gate; + private final MeterRegistry meters; + private final ConcurrentMap rejectionCounters = new ConcurrentHashMap<>(); + + public LicenseEnforcer(LicenseGate gate, MeterRegistry meters) { + this.gate = gate; + this.meters = meters; + } + + public LicenseEnforcer(LicenseGate gate) { + this(gate, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()); + } + + public void assertWithinCap(String limitKey, long currentUsage, long requestedDelta) { + LicenseLimits effective = gate.getEffectiveLimits(); + int cap = effective.get(limitKey); // throws IAE if unknown key + if (currentUsage + requestedDelta > cap) { + rejectionCounters.computeIfAbsent(limitKey, k -> Counter.builder("cameleer_license_cap_rejections_total") + .tag("limit", k).register(meters)).increment(); + throw new LicenseCapExceededException(limitKey, currentUsage + requestedDelta, cap); + } + } +} +``` + +- [ ] **Step 16.4: Run tests** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseEnforcerTest` +Expected: PASS, 4/4. + +- [ ] **Step 16.5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): LicenseEnforcer single entry point + +assertWithinCap consults LicenseGate.getEffectiveLimits, throws +LicenseCapExceededException on overflow, increments +cameleer_license_cap_rejections_total{limit=...} for telemetry. +Unknown limit keys are programmer errors (IllegalArgumentException), +not 403s. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 17: `LicenseUsageReader` + +**Why:** Spec §5 — reads counts/sums for the `/usage` endpoint. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java` + +- [ ] **Step 17.1: Implement `LicenseUsageReader.java`** + +```java +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; + +@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")); + // max_agents is in-memory; AgentRegistryService injects via callback below. + // Compute aggregates: sum across non-stopped deployments. Container config is JSONB. + 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->>'replicas')::int, 1) AS replicas, " + + " COALESCE((d.deployed_config_snapshot->>'cpuLimit')::int, 0) AS cpu_millis, " + + " COALESCE((d.deployed_config_snapshot->>'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; + } + + public long agentCount(int liveAgents) { + return liveAgents; + } + + private long count(String table) { + return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + } +} +``` + +If `deployed_config_snapshot` does not contain numeric `replicas/cpuLimit/memoryLimitMb` fields in your schema (introduced in V3), confirm with `grep -n deployed_config_snapshot cameleer-server-app/src/main/java`. Adjust the JSON paths to match `DeploymentConfigSnapshot` field names. + +- [ ] **Step 17.2: Write IT (boots Spring + Postgres)** + +```java +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); + } +} +``` + +- [ ] **Step 17.3: Run IT** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=LicenseUsageReaderIT` +Expected: PASS. + +- [ ] **Step 17.4: Commit** + +```bash +git add cameleer-server-app/src/{main,test}/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): LicenseUsageReader aggregates current usage + +One COUNT per entity table; one SUM-grouped query over non-stopped +deployments for compute caps. agentCount is fed in by the controller +since it's an in-memory registry value, not a DB row. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 18: Wire enforcement — `max_environments` in `EnvironmentService.create` + +**Why:** Spec §4.1. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` (inject enforcer) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java` + +- [ ] **Step 18.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "EnvironmentService", direction: "upstream"}) +gitnexus_impact({target: "create", direction: "upstream"}) -- (filter to EnvironmentService.create in the report) +``` + +- [ ] **Step 18.2: Add a callback hook to `EnvironmentService`** + +`EnvironmentService` lives in `core` (no Spring) — pass the enforcer in via constructor as a functional interface to keep core decoupled: + +```java +@FunctionalInterface +public interface CreateGuard { + void check(long current); // throws if cap exceeded + CreateGuard NOOP = c -> {}; +} +``` + +Place this `CreateGuard` interface in `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java`. + +Modify `EnvironmentService`: + +```java +public class EnvironmentService { + private final EnvironmentRepository repo; + private final CreateGuard createGuard; + + public EnvironmentService(EnvironmentRepository repo) { + this(repo, CreateGuard.NOOP); + } + + public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) { + this.repo = repo; + this.createGuard = createGuard; + } + + public UUID create(String slug, String displayName, boolean production) { + createGuard.check(repo.count()); + // ... existing body unchanged + } +} +``` + +If `EnvironmentRepository` lacks `count()`, add `long count();` to the interface and implement it in `PostgresEnvironmentRepository` as `jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class)`. + +- [ ] **Step 18.3: Wire in `RuntimeBeanConfig` (or wherever `EnvironmentService` is constructed)** + +Locate the `@Bean public EnvironmentService environmentService(...)` method and replace with: + +```java +@Bean +public EnvironmentService environmentService(EnvironmentRepository repo, + com.cameleer.server.app.license.LicenseEnforcer enforcer) { + return new EnvironmentService(repo, current -> + enforcer.assertWithinCap("max_environments", current, 1)); +} +``` + +- [ ] **Step 18.4: Write failing IT** + +```java +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 org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class EnvironmentCapEnforcementIT extends AbstractPostgresIT { + + @Autowired MockMvc mvc; + + @Test + void createsUpToCap_thenReturns403WithStateAndMessage() throws Exception { + // Default tier: max_environments = 1; V1 seeds the default env, so the next create rejects. + mvc.perform(post("/api/v1/admin/environments") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"slug\":\"prod\",\"displayName\":\"Prod\",\"production\":true}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("license cap reached")) + .andExpect(jsonPath("$.limit").value("max_environments")) + .andExpect(jsonPath("$.cap").value(1)) + .andExpect(jsonPath("$.state").value("ABSENT")) + .andExpect(jsonPath("$.message").exists()); + } +} +``` + +- [ ] **Step 18.5: Run — expect failure first, then PASS after implementation** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=EnvironmentCapEnforcementIT` +Expected: PASS once Steps 18.2–18.3 are applied. + +- [ ] **Step 18.6: Commit** + +```bash +git add cameleer-server-core/src cameleer-server-app/src +git commit -m "$(cat <<'EOF' +feat(license): enforce max_environments at EnvironmentService.create + +Adds CreateGuard functional interface to core (preserves no-Spring +boundary), wires LicenseEnforcer into the EnvironmentService bean +in RuntimeBeanConfig. EnvironmentRepository.count() added. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 19: Wire enforcement — `max_apps` in `AppService.createApp` + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java` + +- [ ] **Step 19.1: Run gitnexus impact** on `AppService` and `createApp`. + +- [ ] **Step 19.2: Add CreateGuard to AppService** (same pattern as Task 18). Add `long count();` to `AppRepository` if missing. + +- [ ] **Step 19.3: Update `createApp`** + +```java +public UUID createApp(UUID environmentId, String slug, String displayName) { + createGuard.check(repo.count()); + // ... existing body +} +``` + +- [ ] **Step 19.4: Wire bean** + +```java +@Bean +public AppService appService(AppRepository repo, com.cameleer.server.app.license.LicenseEnforcer enforcer) { + return new AppService(repo, current -> enforcer.assertWithinCap("max_apps", current, 1)); +} +``` + +- [ ] **Step 19.5: Write IT** — analogous to 18.4, posting to `/api/v1/environments/default/apps` with body `{"slug":"a1","displayName":"A1"}`. Default cap is 3; loop create three then expect 403 on the fourth. + +- [ ] **Step 19.6: Run + Commit** + +```bash +mvn -pl cameleer-server-app verify -Dit.test=AppCapEnforcementIT +git add cameleer-server-core/src cameleer-server-app/src +git commit -m "feat(license): enforce max_apps at AppService.createApp + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 20: Wire enforcement — `max_agents` in `AgentRegistryService.register` + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java` +- Modify: bean config that constructs the registry +- Modify: `AgentRegistrationController` to translate the exception to the same 403 format +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java` + +- [ ] **Step 20.1: Run gitnexus impact** on `AgentRegistryService.register`. + +- [ ] **Step 20.2: Inject CreateGuard into `AgentRegistryService`** + +`register(...)` currently inserts into the in-memory map. Insert at the top: + +```java +createGuard.check(liveAgentCount()); +``` + +Where `liveAgentCount()` is `(int) registry.values().stream().filter(a -> a.state() == AgentState.LIVE).count()`. + +- [ ] **Step 20.3: Wire bean** — `enforcer.assertWithinCap("max_agents", current, 1)`. + +- [ ] **Step 20.4: `AgentRegistrationController` exception handling** + +`AgentRegistrationController.register` currently returns the registered `AgentInfo`. Wrap the registry call in try/catch — if `LicenseCapExceededException` escapes, the global `LicenseExceptionAdvice` returns a 403 automatically; just verify behaviour by catching nothing here. + +- [ ] **Step 20.5: Write IT** — load a license with `max_agents=2`, register two agents, expect 403 on third with `state` and `limit=max_agents`. + +- [ ] **Step 20.6: Run + Commit** as in Task 19. + +--- + +## Task 21: Wire enforcement — `max_users` (admin + OIDC auto-signup) + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java` + +- [ ] **Step 21.1: Run gitnexus impact** on `createUser` and `OidcAuthController.callback`. + +- [ ] **Step 21.2: Inject `LicenseEnforcer` and `UserRepository.count()`** + +In `UserAdminController.createUser` (top of method, before any insert): + +```java +enforcer.assertWithinCap("max_users", userRepository.count(), 1); +``` + +In `OidcAuthController.callback` (auto-signup branch, just before creating the new user): + +```java +if (existingUser.isEmpty() && config.autoSignup()) { + enforcer.assertWithinCap("max_users", userRepository.count(), 1); + // ... existing create +} +``` + +If `UserRepository` lacks `count()`, add `long count();` and implement. + +- [ ] **Step 21.3: Write IT** — login `admin` (V1 seeds it), create users until cap is hit, expect 403 with state + message; verify the `cap_exceeded` audit row exists by querying `/api/v1/admin/audit?category=LICENSE&action=cap_exceeded`. + +- [ ] **Step 21.4: Wire `cap_exceeded` audit emission** + +The advice returns 403 but does not audit. To meet spec §6.5, intercept in `LicenseEnforcer`: + +```java +public LicenseEnforcer(LicenseGate gate, MeterRegistry meters, + AuditService audit /* nullable in unit tests */) { + // ... store all +} +``` + +In `assertWithinCap` after computing the rejection but before throwing: + +```java +if (audit != null) { + Map payload = new LinkedHashMap<>(); + payload.put("limit", limitKey); + payload.put("current", currentUsage + requestedDelta); + payload.put("cap", cap); + payload.put("state", gate.getState().name()); + String requester = currentRequester(); // helper that reads SecurityContextHolder + audit.record(AuditCategory.LICENSE, "cap_exceeded", requester, payload); +} +``` + +`currentRequester()` strips the `user:` prefix per the existing convention in `app-classes.md`. + +Add a 3-arg constructor and update existing 1-arg/2-arg constructors and tests accordingly. The 1-arg test constructor should pass `null` for audit. + +- [ ] **Step 21.5: Run + Commit**. + +--- + +## Task 22: Wire enforcement — `max_outbound_connections` in `OutboundConnectionServiceImpl.create` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java` + +- [ ] **Step 22.1: gitnexus impact** on `OutboundConnectionServiceImpl.create`. +- [ ] **Step 22.2: Inject enforcer + repo count**. At top of `create(...)`: + +```java +enforcer.assertWithinCap("max_outbound_connections", repo.listByTenant(tenantId).size(), 1); +``` + +- [ ] **Step 22.3: IT** — default cap=1; create one then expect 403 on second. +- [ ] **Step 22.4: Run + Commit**. + +--- + +## Task 23: Wire enforcement — `max_alert_rules` in `AlertRuleController.create` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java` (path may vary; locate via `gitnexus_context({name: "AlertRuleController"})`) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java` + +- [ ] **Step 23.1: gitnexus impact** on `AlertRuleController.create`. +- [ ] **Step 23.2: Inject enforcer + `AlertRuleRepository.count()`**. At top of POST handler: + +```java +enforcer.assertWithinCap("max_alert_rules", alertRuleRepository.count(), 1); +``` + +- [ ] **Step 23.3: IT** — default cap=2; create two then expect 403 on third. +- [ ] **Step 23.4: Run + Commit**. + +--- + +## Task 24: Wire enforcement — compute caps in `DeploymentExecutor.PRE_FLIGHT` + +**Why:** Spec §4.1 — sum cpu/memory/replicas across non-stopped deployments + new request must fit within caps. + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java` + +- [ ] **Step 24.1: gitnexus impact** on `DeploymentExecutor` and the `PRE_FLIGHT` stage method. + +- [ ] **Step 24.2: Add a compute aggregator (lives on `LicenseUsageReader` from Task 17)** + +Add to `LicenseUsageReader`: + +```java +public record ComputeUsage(long cpuMillis, long memoryMb, long replicas) {} + +public ComputeUsage computeUsage() { + var snap = snapshot(); + return new ComputeUsage( + snap.getOrDefault("max_total_cpu_millis", 0L), + snap.getOrDefault("max_total_memory_mb", 0L), + snap.getOrDefault("max_total_replicas", 0L)); +} +``` + +- [ ] **Step 24.3: In `DeploymentExecutor.preFlight(...)`** + +After resolving config but before any container creation: + +```java +var resolved = config; // ResolvedContainerConfig already computed +int reqCpu = resolved.cpuLimit() == null ? 0 : resolved.cpuLimit(); +int reqMem = resolved.memoryLimitMb() == null ? 0 : resolved.memoryLimitMb(); +int reqReps = resolved.replicas(); + +var usage = usageReader.computeUsage(); +enforcer.assertWithinCap("max_total_cpu_millis", usage.cpuMillis(), (long) reqCpu * reqReps); +enforcer.assertWithinCap("max_total_memory_mb", usage.memoryMb(), (long) reqMem * reqReps); +enforcer.assertWithinCap("max_total_replicas", usage.replicas(), reqReps); +``` + +The exception bubbles, the executor catches, marks deployment FAILED with the validator message, audit row already covered by §6.5 cap_exceeded. + +- [ ] **Step 24.4: IT** — create a deployment with `cpuLimit=3000` while default cap is 2000; expect deploy to land in FAILED state with the failure reason containing `max_total_cpu_millis`. + +- [ ] **Step 24.5: Run + Commit**. + +--- + +## Task 25: Wire enforcement — retention caps + `max_jar_retention_count` + +**Why:** Spec §4.1. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` (add `update(...)` retention validation) +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java` (PUT /jar-retention) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java` + +- [ ] **Step 25.1: Add retention caps to update path** + +In `EnvironmentService.update(...)` (or the Postgres repo's update query), validate before save: + +```java +var enforcer = retentionEnforcer; // injected functional handle +enforcer.checkRetention("max_execution_retention_days", req.executionRetentionDays()); +enforcer.checkRetention("max_log_retention_days", req.logRetentionDays()); +enforcer.checkRetention("max_metric_retention_days", req.metricRetentionDays()); +``` + +`RetentionEnforcer` is a small wrapper: + +```java +@FunctionalInterface +public interface RetentionEnforcer { + void checkRetention(String key, int requested); +} +``` + +In the bean wiring, supply: + +```java +RetentionEnforcer re = (k, v) -> { + int cap = enforcer.gate().getEffectiveLimits().get(k); + if (v > cap) throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "retention " + k + "=" + v + " exceeds license cap " + cap); +}; +``` + +(Add `LicenseGate gate()` accessor to `LicenseEnforcer` so the wrapper can read effective limits, OR pass `LicenseGate` directly.) + +- [ ] **Step 25.2: Apply to `EnvironmentAdminController.PUT /jar-retention`** + +```java +int cap = licenseGate.getEffectiveLimits().get("max_jar_retention_count"); +if (request.jarRetentionCount() > cap) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "jarRetentionCount " + request.jarRetentionCount() + " exceeds license cap " + cap); +} +``` + +- [ ] **Step 25.3: IT** — PUT a retention of 30 days into the default-tier server (cap=1); expect 422. + +- [ ] **Step 25.4: Run + Commit**. + +--- + +## Task 26: Add `Environment` retention fields + repository changes + +**Why:** Spec §4.2 — store + read the new columns on `environments`. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java` +- Modify: any DTO/controller that returns env fields to the UI (`EnvironmentAdminController`) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresEnvironmentRepositoryIT.java` (extend with new columns) + +- [ ] **Step 26.1: Update the record** + +Add three `int` fields after `jarRetentionCount`: + +```java +public record Environment( + UUID id, String slug, String displayName, boolean production, + boolean enabled, ContainerConfig defaultContainerConfig, + int jarRetentionCount, String color, Instant createdAt, + int executionRetentionDays, int logRetentionDays, int metricRetentionDays +) { /* validators preserved */ } +``` + +Update all `new Environment(...)` call sites — there will be several. Use `gitnexus_context({name: "Environment"})` to enumerate. + +- [ ] **Step 26.2: Update `PostgresEnvironmentRepository`** — `INSERT`, `UPDATE`, and the `RowMapper` to include the three new columns. + +- [ ] **Step 26.3: Surface on the wire** + +Add the three fields to the `EnvironmentDto` (or whatever the admin controller returns) and the `UpdateEnvironmentRequest`. + +- [ ] **Step 26.4: Run unit + ITs** + +`mvn -pl cameleer-server-app test -DskipITs && mvn -pl cameleer-server-app verify -Dit.test=PostgresEnvironmentRepositoryIT` + +- [ ] **Step 26.5: Commit**. + +--- + +## Task 27: `RetentionPolicyApplier` — runtime ClickHouse TTL recompute + +**Why:** Spec §4.3. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionPolicyApplierTest.java` + +- [ ] **Step 27.1: Implement applier** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.runtime.Environment; +import com.cameleer.server.core.runtime.EnvironmentRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class RetentionPolicyApplier { + + private static final Logger log = LoggerFactory.getLogger(RetentionPolicyApplier.class); + + private static final Map TABLE_TO_LIMIT = Map.of( + "executions", "max_execution_retention_days", + "processors", "max_execution_retention_days", + "logs", "max_log_retention_days", + "metrics", "max_metric_retention_days", + "agent_events", "max_metric_retention_days", + "route_diagrams", "max_metric_retention_days" + ); + + private final LicenseGate gate; + private final EnvironmentRepository envRepo; + private final JdbcTemplate clickhouseJdbc; + + public RetentionPolicyApplier(LicenseGate gate, EnvironmentRepository envRepo, + @Qualifier("clickhouseJdbcTemplate") JdbcTemplate clickhouseJdbc) { + this.gate = gate; + this.envRepo = envRepo; + this.clickhouseJdbc = clickhouseJdbc; + } + + @EventListener(LicenseChangedEvent.class) + @Async + public void onLicenseChanged(LicenseChangedEvent ignored) { + var limits = gate.getEffectiveLimits(); + for (Environment env : envRepo.findAll()) { + apply(env, limits.get("max_execution_retention_days"), env.executionRetentionDays(), "executions"); + apply(env, limits.get("max_execution_retention_days"), env.executionRetentionDays(), "processors"); + apply(env, limits.get("max_log_retention_days"), env.logRetentionDays(), "logs"); + apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "metrics"); + apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "agent_events"); + apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "route_diagrams"); + } + } + + private void apply(Environment env, int cap, int configured, String table) { + int effective = Math.min(cap, configured); + // ClickHouse partition is by (tenant_id, toYYYYMM(timestamp)); per-env TTL is enforced via TTL clause that reads environment column. + // We use a single ALTER TABLE per call; ClickHouse will only re-evaluate on its next merge. + String sql = "ALTER TABLE " + table + " MODIFY TTL timestamp + INTERVAL " + effective + " DAY WHERE environment = '" + env.slug().replace("'", "''") + "'"; + try { + clickhouseJdbc.execute(sql); + log.info("Applied TTL: table={} env={} days={}", table, env.slug(), effective); + } catch (Exception e) { + log.warn("Failed to apply TTL for {}/{}: {}", table, env.slug(), e.getMessage()); + } + } +} +``` + +NB: Per-env TTL via `WHERE environment = '...'` requires ClickHouse 22.x+. If the project's CH does not support per-env TTL, fall back to a global TTL = `min(licenseCap, max(env.configured))`. Verify against `init.sql`. + +- [ ] **Step 27.2: Unit test (mock JdbcTemplate, EnvRepository)** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.*; +import com.cameleer.server.core.runtime.Environment; +import com.cameleer.server.core.runtime.EnvironmentRepository; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.*; + +class RetentionPolicyApplierTest { + + @Test + void onChange_emitsAlterPerTablePerEnv() { + LicenseGate gate = new LicenseGate(); + EnvironmentRepository envRepo = mock(EnvironmentRepository.class); + JdbcTemplate ch = mock(JdbcTemplate.class); + when(envRepo.findAll()).thenReturn(List.of( + new Environment(UUID.randomUUID(), "default", "Default", false, true, null, + 3, "slate", Instant.now(), 7, 7, 14) + )); + new RetentionPolicyApplier(gate, envRepo, ch) + .onLicenseChanged(new LicenseChangedEvent(LicenseState.ABSENT, null)); + + verify(ch, atLeast(1)).execute(contains("ALTER TABLE")); + } + + @Test + void chFailure_doesNotPropagate() { + LicenseGate gate = new LicenseGate(); + EnvironmentRepository envRepo = mock(EnvironmentRepository.class); + JdbcTemplate ch = mock(JdbcTemplate.class); + doThrow(new RuntimeException("ch down")).when(ch).execute(anyString()); + when(envRepo.findAll()).thenReturn(List.of( + new Environment(UUID.randomUUID(), "default", "Default", false, true, null, + 3, "slate", Instant.now(), 7, 7, 14) + )); + new RetentionPolicyApplier(gate, envRepo, ch) + .onLicenseChanged(new LicenseChangedEvent(LicenseState.ABSENT, null)); + // no exception + } +} +``` + +- [ ] **Step 27.3: Run + Commit**. + +--- + +## Task 28: `LicenseRevalidationJob` + +**Why:** Spec §6.6. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java` + +- [ ] **Step 28.1: Implement** + +```java +package com.cameleer.server.app.license; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class LicenseRevalidationJob { + + private static final Logger log = LoggerFactory.getLogger(LicenseRevalidationJob.class); + private final LicenseService svc; + + public LicenseRevalidationJob(LicenseService svc) { + this.svc = svc; + } + + @EventListener(ApplicationReadyEvent.class) + public void onStartup() { + try { Thread.sleep(60_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } + revalidate(); + } + + @Scheduled(cron = "0 0 3 * * *") + public void daily() { + revalidate(); + } + + private void revalidate() { + try { + svc.revalidate(); + } catch (Exception e) { + log.error("Revalidation crashed: {}", e.getMessage()); + } + } +} +``` + +Ensure `@EnableScheduling` is present in the Spring config (likely in `Application.java` or a common config). + +- [ ] **Step 28.2: Unit test** + +```java +package com.cameleer.server.app.license; + +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +class LicenseRevalidationJobTest { + + @Test + void daily_callsService() { + LicenseService svc = mock(LicenseService.class); + new LicenseRevalidationJob(svc).daily(); + verify(svc).revalidate(); + } +} +``` + +- [ ] **Step 28.3: Run + Commit**. + +--- + +## Task 29: Extend `LicenseAdminController` to delegate to `LicenseService` and return state + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` + +- [ ] **Step 29.1: gitnexus impact** on `LicenseAdminController`. + +- [ ] **Step 29.2: Replace controller** + +```java +@RestController +@RequestMapping("/api/v1/admin/license") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "License Admin", description = "License management") +public class LicenseAdminController { + + private final LicenseService licenseService; + private final LicenseGate gate; + private final LicenseRepository repo; + + public LicenseAdminController(LicenseService svc, LicenseGate gate, LicenseRepository repo) { + this.licenseService = svc; + this.gate = gate; + this.repo = repo; + } + + @GetMapping + public ResponseEntity> getCurrent() { + Map body = new LinkedHashMap<>(); + body.put("state", gate.getState().name()); + body.put("invalidReason", gate.getInvalidReason()); + body.put("envelope", gate.getCurrent()); // null when ABSENT/INVALID; raw token deliberately omitted + repo.findByTenantId(licenseService.getTenantId()).ifPresent(rec -> + body.put("lastValidatedAt", rec.lastValidatedAt().toString())); + return ResponseEntity.ok(body); + } + + record UpdateLicenseRequest(String token) {} + + @PostMapping + public ResponseEntity update(@RequestBody UpdateLicenseRequest request, Authentication auth) { + String userId = auth == null ? "system" : auth.getName().replaceFirst("^user:", ""); + try { + var info = licenseService.install(request.token(), userId, "api"); + return ResponseEntity.ok(Map.of( + "state", gate.getState().name(), + "envelope", info)); + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } +} +``` + +- [ ] **Step 29.3: Run + Commit**. + +--- + +## Task 30: `LicenseUsageController` + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java` + +- [ ] **Step 30.1: Implement** + +```java +@RestController +@RequestMapping("/api/v1/admin/license/usage") +@PreAuthorize("hasRole('ADMIN')") +public class LicenseUsageController { + + private final LicenseGate gate; + private final LicenseUsageReader reader; + private final AgentRegistryService agents; // for live agent count + private final LicenseService svc; + private final LicenseRepository repo; + + public LicenseUsageController(LicenseGate gate, LicenseUsageReader reader, + AgentRegistryService agents, LicenseService svc, LicenseRepository repo) { + this.gate = gate; this.reader = reader; this.agents = agents; this.svc = svc; this.repo = repo; + } + + @GetMapping + public ResponseEntity> get() { + var state = gate.getState(); + var info = gate.getCurrent(); + var effective = gate.getEffectiveLimits(); + var usage = new java.util.HashMap<>(reader.snapshot()); + usage.put("max_agents", (long) agents.liveCount()); + + List> limitRows = new java.util.ArrayList<>(); + for (var key : effective.values().keySet()) { + Map row = new LinkedHashMap<>(); + row.put("key", key); + row.put("current", usage.getOrDefault(key, 0L)); + row.put("cap", effective.get(key)); + row.put("source", info != null && info.limits().containsKey(key) ? "license" : "default"); + limitRows.add(row); + } + + Map body = new LinkedHashMap<>(); + body.put("state", state.name()); + body.put("expiresAt", info == null ? null : info.expiresAt().toString()); + body.put("daysRemaining", info == null ? null : + java.time.Duration.between(java.time.Instant.now(), info.expiresAt()).toDays()); + body.put("gracePeriodDays", info == null ? 0 : info.gracePeriodDays()); + body.put("tenantId", info == null ? null : info.tenantId()); + body.put("label", info == null ? null : info.label()); + repo.findByTenantId(svc.getTenantId()).ifPresent(rec -> + body.put("lastValidatedAt", rec.lastValidatedAt().toString())); + body.put("message", LicenseMessageRenderer.forState(state, info, gate.getInvalidReason())); + body.put("limits", limitRows); + return ResponseEntity.ok(body); + } +} +``` + +Add a sibling `LicenseMessageRenderer.forState(...)` (parallel to `forCap`) emitting the §5 templates (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID). + +Add a `liveCount()` method to `AgentRegistryService` if not already exposed. + +- [ ] **Step 30.2: Write IT** — assert response shape includes `state`, `limits[]` with `key/current/cap/source`, `message`, `lastValidatedAt`. + +- [ ] **Step 30.3: Run + Commit**. + +--- + +## Task 31: `LicenseMetrics` + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java` + +- [ ] **Step 31.1: Implement** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseState; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +@Component +public class LicenseMetrics { + + private final LicenseGate gate; + private final LicenseUsageReader usage; + private final LicenseRepository repo; + private final String tenantId; + + private final Map> stateGauges = new EnumMap<>(LicenseState.class); + private final AtomicReference daysRemaining = new AtomicReference<>(0.0); + private final AtomicReference validatedAge = new AtomicReference<>(0.0); + + public LicenseMetrics(LicenseGate gate, LicenseUsageReader usage, + LicenseRepository repo, MeterRegistry meters, + @org.springframework.beans.factory.annotation.Value("${cameleer.server.tenant.id:default}") String tenantId) { + this.gate = gate; this.usage = usage; this.repo = repo; this.tenantId = tenantId; + for (var s : LicenseState.values()) { + var ref = new AtomicReference<>(0.0); + stateGauges.put(s, ref); + Gauge.builder("cameleer_license_state", ref, AtomicReference::get) + .tag("state", s.name()).register(meters); + } + Gauge.builder("cameleer_license_days_remaining", daysRemaining, AtomicReference::get).register(meters); + Gauge.builder("cameleer_license_last_validated_age_seconds", validatedAge, AtomicReference::get).register(meters); + // Per-limit utilisation registered lazily on first compute via tag-aware Gauge.builder + a ConcurrentMap. + } + + @EventListener(LicenseChangedEvent.class) + @Scheduled(fixedDelay = 60_000) + public void refresh() { + var state = gate.getState(); + for (var s : LicenseState.values()) { + stateGauges.get(s).set(s == state ? 1.0 : 0.0); + } + var info = gate.getCurrent(); + daysRemaining.set(info == null ? -1.0 + : (double) Duration.between(Instant.now(), info.expiresAt()).toDays()); + repo.findByTenantId(tenantId).ifPresent(rec -> + validatedAge.set((double) Duration.between(rec.lastValidatedAt(), Instant.now()).toSeconds())); + } +} +``` + +(Per-limit `cameleer_license_limit_utilisation` is omitted from this skeleton to keep the file short — extend with a `Map>` and register one gauge per key on first refresh.) + +- [ ] **Step 31.2: Run + Commit**. + +--- + +## Task 32: Integration test — `LicenseLifecycleIT` + +**Why:** Spec §10 — install via env / replace via POST / restart restores from DB / public-key removal → INVALID / daily revalidation updates timestamp. + +**Files:** +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java` + +- [ ] **Step 32.1: Skeleton** + +```java +class LicenseLifecycleIT extends AbstractPostgresIT { + + @Autowired MockMvc mvc; + @Autowired LicenseService svc; + @Autowired LicenseGate gate; + @Autowired LicenseRepository repo; + + @Test + void install_persists_andSurvivesGateClear() throws Exception { + // Mint a token via LicenseMinter (test pulls in cameleer-license-minter dep — add as test scope) + // POST to /api/v1/admin/license; assert state=ACTIVE; clear gate; reload via svc.loadInitial; assert state=ACTIVE again from DB. + } + + @Test + void postWithBadSignature_marksInvalid() { + // POST a tampered token; expect 400; assert gate.getState() == INVALID; assert audit row exists. + } + + @Test + void revalidateAfterPublicKeyChange_marksInvalid() { + // Install valid token; rebuild validator with a different public key (or simulate by direct gate.markInvalid via svc.revalidate after corrupting DB token). + } +} +``` + +Flesh out each test fully. Add `cameleer-license-minter` as a `test` dependency on `cameleer-server-app/pom.xml` for this IT (acceptable: minter on test classpath only does not leak it into the runtime jar). + +- [ ] **Step 32.2: Run + Commit**. + +--- + +## Task 33: Integration test — `LicenseEnforcementIT` + +**Why:** Spec §10 — REST-driven, hits each cap end-to-end + verifies `cap_exceeded` audit + 403 message. + +**Files:** +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java` + +- [ ] **Step 33.1: One @Nested per limit** — install a license raising the cap, drive the REST endpoint until full, assert 403 body shape, query audit endpoint to confirm `cap_exceeded` row. + +- [ ] **Step 33.2: Run + Commit**. + +--- + +## Task 34: Integration test — `RetentionRuntimeRecomputeIT` + +**Why:** Spec §4.3. + +**Files:** +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java` + +- [ ] **Step 34.1: Steps** + +1. Install license with `max_log_retention_days=30`. +2. Read TTL via `SELECT engine_full FROM system.tables WHERE name='logs'` on the test ClickHouse Testcontainer; assert `INTERVAL 30 DAY`. +3. Replace license with `max_log_retention_days=7`. +4. Re-read TTL; assert `INTERVAL 7 DAY` (allow up to 5s for the @Async event listener; poll). + +- [ ] **Step 34.2: Run + Commit**. + +--- + +## Task 35: Extend `SchemaBootstrapIT` + regenerate OpenAPI types + +**Files:** +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java` +- Regen: `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` + +- [ ] **Step 35.1: Extend `SchemaBootstrapIT`** + +Add asserts: `license` table exists with all columns including `last_validated_at`; `environments` has 3 retention columns. + +- [ ] **Step 35.2: Regenerate OpenAPI** + +Per `CLAUDE.md`: + +```bash +mvn -pl cameleer-server-app spring-boot:run & # background +sleep 30 +cd ui && npm run generate-api:live +kill %1 +``` + +(Or use the project's standard procedure if different.) + +Fix any TypeScript compile errors in the SPA call sites for the modified `/admin/license` GET response shape and the new `/admin/license/usage` endpoint. + +- [ ] **Step 35.3: Commit** — separate commit for backend, separate for SPA. + +--- + +## Task 36: Update `.claude/rules/` + +**Why:** Per `CLAUDE.md` — class/API map must stay in sync with the code. + +**Files:** +- Modify: `.claude/rules/core-classes.md` +- Modify: `.claude/rules/app-classes.md` +- Modify: `.claude/rules/gitnexus.md` (only if symbol counts change after `npx gitnexus analyze`) + +- [ ] **Step 36.1: Append to `.claude/rules/core-classes.md`** under `## license/` + +Document: `LicenseInfo` (new shape), `LicenseLimits`, `LicenseValidator(publicKey, expectedTenantId)`, `LicenseGate.{getState,getEffectiveLimits,markInvalid,clear}`, `LicenseStateMachine.classify`, `LicenseState`, `DefaultTierLimits.DEFAULTS`, `Environment` (new retention fields), `CreateGuard`. + +- [ ] **Step 36.2: Append to `.claude/rules/app-classes.md`** + +Document: `LicenseService`, `LicenseRepository`/`PostgresLicenseRepository`/`LicenseRecord`, `LicenseEnforcer.assertWithinCap`, `LicenseUsageReader.{snapshot, computeUsage, agentCount}`, `LicenseCapExceededException`, `LicenseExceptionAdvice` (403 mapping), `LicenseMessageRenderer`, `LicenseRevalidationJob`, `RetentionPolicyApplier`, `LicenseMetrics`, `LicenseUsageController` (`GET /api/v1/admin/license/usage`), revised `LicenseAdminController`, `AuditCategory.LICENSE`. + +Add `/api/v1/admin/license/usage` to the **Admin (cross-env, flat)** sub-section. + +- [ ] **Step 36.3: Run gitnexus analyze** + +```bash +npx gitnexus analyze --embeddings +``` + +Update `.claude/rules/gitnexus.md` with the new symbol/relationship counts shown by the tool. + +- [ ] **Step 36.4: Commit** + +```bash +git add .claude/rules +git commit -m "$(cat <<'EOF' +docs(rules): document license enforcement classes + endpoints + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review checklist (run after Task 36 completes) + +- [ ] All 13 spec limit keys have an enforcement task and an IT assertion. +- [ ] `LicenseChangedEvent` is published from boot, install, and revalidation paths. +- [ ] `RetentionPolicyApplier` is invoked on every change (boot + replace + revalidation). +- [ ] `cap_exceeded` audit row carries `state`. +- [ ] 403 body contains `state` + `message`. +- [ ] OpenAPI types regenerated; no stale TS compile errors. +- [ ] `.claude/rules/*.md` updated in the same series of commits. +- [ ] Default-tier server cannot create a 2nd environment, 4th app, 6th agent, etc., per §3.2. +- [ ] `cameleer-server-app` does NOT compile-depend on `cameleer-license-minter` (verify `mvn -pl cameleer-server-app dependency:tree | grep license-minter` → empty). + +