# 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) <noreply@anthropic.com>`.

---

## 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<String, Integer> 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<LicenseInfo> 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<Feature> 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) <noreply@anthropic.com>
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<String, Integer> 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) <noreply@anthropic.com>
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<String, Integer> 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) <noreply@anthropic.com>
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<String, Integer> DEFAULTS;

    static {
        Map<String, Integer> 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<String, Integer> values) {

    public LicenseLimits {
        Objects.requireNonNull(values, "values");
    }

    public static LicenseLimits defaultsOnly() {
        return new LicenseLimits(DefaultTierLimits.DEFAULTS);
    }

    public static LicenseLimits mergeOverDefaults(Map<String, Integer> overrides) {
        java.util.Map<String, Integer> 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) <noreply@anthropic.com>
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<Snapshot> 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) <noreply@anthropic.com>
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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.cameleer</groupId>
        <artifactId>cameleer-server-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>cameleer-license-minter</artifactId>
    <name>Cameleer License Minter</name>
    <description>Vendor-only Ed25519 license signing library + CLI</description>

    <dependencies>
        <dependency>
            <groupId>com.cameleer</groupId>
            <artifactId>cameleer-server-core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <id>repackage-cli</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                        <configuration>
                            <classifier>cli</classifier>
                            <mainClass>com.cameleer.license.minter.cli.LicenseMinterCli</mainClass>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>
```

- [ ] **Step 6.3: Register the module in root `pom.xml`**

Edit `pom.xml`, locate the `<modules>` block, add the new entry:

```xml
<modules>
    <module>cameleer-server-core</module>
    <module>cameleer-server-app</module>
    <module>cameleer-license-minter</module>
</modules>
```

- [ ] **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) <noreply@anthropic.com>
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.<String, Integer>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<String, Integer> 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<String, Integer> 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) <noreply@anthropic.com>
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<String> 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<String, String> flags = new LinkedHashMap<>();
        Set<String> bool = new HashSet<>();
        Map<String, Integer> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<LicenseRecord> 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<LicenseRecord> 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<LicenseRecord> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<String, Object> 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<LicenseRecord> 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<String, Object> 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<String> envToken, Optional<String> 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<LicenseRecord> 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<LicenseRecord> 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<String, Object> 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) <noreply@anthropic.com>
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<String> env = (envToken != null && !envToken.isBlank())
                    ? Optional.of(envToken) : Optional.empty();
            Optional<String> 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) <noreply@anthropic.com>
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<Map<String, Object>> handle(LicenseCapExceededException e) {
        var state = gate.getState();
        LicenseInfo info = gate.getCurrent();
        String reason = gate.getInvalidReason();
        Map<String, Object> 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) <noreply@anthropic.com>
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<String, Integer> 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<String, Counter> 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) <noreply@anthropic.com>
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<String, Long> snapshot() {
        Map<String, Long> 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<String, Long> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>"
```

---

## 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<String, Object> 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<String, String> 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<Map<String, Object>> getCurrent() {
        Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> limitRows = new java.util.ArrayList<>();
        for (var key : effective.values().keySet()) {
            Map<String, Object> 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<String, Object> 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<LicenseState, AtomicReference<Double>> stateGauges = new EnumMap<>(LicenseState.class);
    private final AtomicReference<Double> daysRemaining = new AtomicReference<>(0.0);
    private final AtomicReference<Double> 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<String, AtomicReference<Double>>` 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 `<scope>test</scope>` 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) <noreply@anthropic.com>
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).


