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>
147 KiB
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 testormvn -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 withCo-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.javacameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.javacameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.javacameleer-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.javacameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.javacameleer-license-minter/pom.xmlcameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.javacameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.javacameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.javacameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.javacameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sqlcameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.javacameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.javacameleer-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(dropfeatures, addlicenseId/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, dropfeatures)cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java(dropisEnabled(Feature); addgetEffectiveLimits()/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(addLICENSE)- All "wire enforcement" call sites listed in Tasks 18–25
pom.xml(root: registercameleer-license-mintermodule).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
featuresportions ofLicenseInfo,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.javawith the new shape (still withoutlicenseId/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(dropisEnabled)
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 thefeaturesparsing block
In the validate(String token) method, delete the entire Set<Feature> features = ... block (lines 59–68 of the original) and remove the corresponding constructor arg from the new LicenseInfo(...) call so it matches Step 1.3's shape:
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— removefeaturesfrom sample payloads andFeatureassertions
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.javato 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.javabody
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 viagrep -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
LICENSEto 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 whereverEnvironmentServiceis 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.2–18.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
AppServiceandcreateApp. -
Step 19.2: Add CreateGuard to AppService (same pattern as Task 18). Add
long count();toAppRepositoryif 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/appswith 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:
AgentRegistrationControllerto 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 bean —
enforcer.assertWithinCap("max_agents", current, 1). -
Step 20.4:
AgentRegistrationControllerexception 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 withstateandlimit=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
createUserandOidcAuthController.callback. -
Step 21.2: Inject
LicenseEnforcerandUserRepository.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 thecap_exceededaudit row exists by querying/api/v1/admin/audit?category=LICENSE&action=cap_exceeded. -
Step 21.4: Wire
cap_exceededaudit 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 viagitnexus_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
DeploymentExecutorand thePRE_FLIGHTstage method. -
Step 24.2: Add a compute aggregator (lives on
LicenseUsageReaderfrom 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=3000while default cap is 2000; expect deploy to land in FAILED state with the failure reason containingmax_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(addupdate(...)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
PostgresEnvironmentRepository—INSERT,UPDATE, and theRowMapperto 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[]withkey/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_exceededrow. -
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
- Install license with
max_log_retention_days=30. - Read TTL via
SELECT engine_full FROM system.tables WHERE name='logs'on the test ClickHouse Testcontainer; assertINTERVAL 30 DAY. - Replace license with
max_log_retention_days=7. - 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 afternpx gitnexus analyze) -
Step 36.1: Append to
.claude/rules/core-classes.mdunder## 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.
LicenseChangedEventis published from boot, install, and revalidation paths.RetentionPolicyApplieris invoked on every change (boot + replace + revalidation).cap_exceededaudit row carriesstate.- 403 body contains
state+message. - OpenAPI types regenerated; no stale TS compile errors.
.claude/rules/*.mdupdated in the same series of commits.- Default-tier server cannot create a 2nd environment, 4th app, 6th agent, etc., per §3.2.
cameleer-server-appdoes NOT compile-depend oncameleer-license-minter(verifymvn -pl cameleer-server-app dependency:tree | grep license-minter→ empty).