Files
cameleer-server/docs/superpowers/plans/2026-04-25-license-enforcement.md
hsiegeln ec51aef802 docs(license): implementation plan for license enforcement
36 tasks covering: dead-Feature removal; LicenseInfo/Limits/State
machine; standalone cameleer-license-minter Maven module + CLI with
--verify; Flyway V5 license table + environments retention columns;
LicenseRepository/Service/Enforcer/UsageReader; per-state cap-rejection
ControllerAdvice with rendered messages; wiring across Environment/
App/Agent/User/Outbound/AlertRule/Deployment compute caps; runtime
ClickHouse TTL applier on every LicenseChangedEvent; daily
revalidation job; usage endpoint; Prometheus gauges; ITs; OpenAPI
regen; .claude/rules updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:09:28 +02:00

147 KiB
Raw Blame History

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 1825
  • 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
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)
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)
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 5968 of the original) and remove the corresponding constructor arg from the new LicenseInfo(...) call so it matches Step 1.3's shape:

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:

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:

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:

assertThat(info.hasFeature(Feature.debugger)).isTrue();
assertThat(info.hasFeature(Feature.replay)).isFalse();

In validate_expiredLicense_throwsException, replace its payload with:

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:

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
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:

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
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:

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:

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
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):

@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:

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:

LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");

Add asserts:

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
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:

@Value("${cameleer.server.tenant.id:default}")
private String tenantId;

And change both call sites:

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
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

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
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
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
package com.cameleer.server.core.license;

public enum LicenseState {
    ABSENT,
    ACTIVE,
    GRACE,
    EXPIRED,
    INVALID
}
  • Step 4.6: Write failing test — LicenseStateMachineTest.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
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
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
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
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
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

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 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:

<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
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

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:

@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
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
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

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
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
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:

@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;:

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
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

-- 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
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:

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:

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)
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
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:

@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
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
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
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

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
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
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
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
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
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

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
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
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
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
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

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
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
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

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)
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
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:

@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:

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:

@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
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.218.3 are applied.

  • Step 18.6: Commit
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

public UUID createApp(UUID environmentId, String slug, String displayName) {
    createGuard.check(repo.count());
    // ... existing body
}
  • Step 19.4: Wire bean
@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

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:

createGuard.check(liveAgentCount());

Where liveAgentCount() is (int) registry.values().stream().filter(a -> a.state() == AgentState.LIVE).count().

  • Step 20.3: Wire beanenforcer.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):

enforcer.assertWithinCap("max_users", userRepository.count(), 1);

In OidcAuthController.callback (auto-signup branch, just before creating the new user):

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:

public LicenseEnforcer(LicenseGate gate, MeterRegistry meters,
                       AuditService audit /* nullable in unit tests */) {
    // ... store all
}

In assertWithinCap after computing the rejection but before throwing:

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(...):

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:

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:

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:

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:

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:

@FunctionalInterface
public interface RetentionEnforcer {
    void checkRetention(String key, int requested);
}

In the bean wiring, supply:

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
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:

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 PostgresEnvironmentRepositoryINSERT, 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

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)
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

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
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

@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

@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

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

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:

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
npx gitnexus analyze --embeddings

Update .claude/rules/gitnexus.md with the new symbol/relationship counts shown by the tool.

  • Step 36.4: Commit
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).