From 6f658b6648ca38634560bb393a1869a449601b7f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:07:35 +0200 Subject: [PATCH] docs(license): session handoff at task 14/36 Resume point for the next session executing the License Enforcement plan. Captures: 14 done commit SHAs, what works/doesn't end-to-end, critical plan deviations (AuditService.log API; LicenseInfo.label not tier; throwaway-keypair fallback validator; ClickHouse TTL WHERE caveat for T27), batching strategy, and suggested next-task order. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-26-license-enforcement-handoff.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md diff --git a/docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md b/docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md new file mode 100644 index 00000000..4f04d961 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md @@ -0,0 +1,171 @@ +# License Enforcement — Session Handoff (2026-04-26) + +> Pick up at **Task 15** in `docs/superpowers/plans/2026-04-25-license-enforcement.md`. + +## Where we are + +- **Branch:** `feature/runtime-hardening` (the three doc commits + 14 implementation commits stack on top of unrelated runtime-hardening work — extract to a clean branch later). +- **Last implementation commit:** `b95e80a2` — `feat(license): wire LicenseService into boot order (env > file > DB)`. +- **Tasks 1–14 of 36 are complete and committed.** All tests green. +- **Plan file:** `docs/superpowers/plans/2026-04-25-license-enforcement.md` — 36 tasks, ~4083 lines. +- **Spec file:** `docs/superpowers/specs/2026-04-25-license-enforcement-design.md`. + +## Resume command for the next session + +``` +Resume the License Enforcement implementation at Task 15. +Plan: docs/superpowers/plans/2026-04-25-license-enforcement.md +Handoff: docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md +Last commit: b95e80a2. 14 of 36 tasks done. Branch: feature/runtime-hardening. +Use Subagent-Driven Development. Continue batching where it makes sense +(see "Batching strategy" below). +``` + +## Done, with commit SHAs + +| # | SHA | Subject | +|---|---|---| +| 1 | `551a7f12` | `refactor(license): remove dead Feature enum and isEnabled scaffolding` | +| 2 | `2ebe4989` | `feat(license): expand LicenseInfo with licenseId, tenantId, grace period` | +| 3 | `cf84d80d` | `feat(license): require licenseId + tenantId in validator` | +| 4 | `ddc0b686` | `feat(license): add LicenseLimits, DefaultTierLimits, LicenseStateMachine` | +| 5 | `0499a54e` | `feat(license): rewrite LicenseGate around state + effective limits` | +| 6 | `896b7e6e` | `feat(license-minter): add cameleer-license-minter Maven module` | +| 7 | `1ae5a1a2` | `feat(license-minter): implement LicenseMinter library` | +| 8 | `7300424a` | `feat(license-minter): add LicenseMinterCli (without --verify)` | +| 9 | `f6657f81` | `feat(license-minter): --verify round-trips before shipping` | +| 10 | `20aefd5b` | `feat(license): Flyway V5 — license table + environments retention columns` | +| 11 | `2e51deb5` | `feat(license): PostgresLicenseRepository + LicenseRecord` | +| 12 | `2f75b286` | `feat(license): add AuditCategory.LICENSE` | +| 13 | `6fbcf10e` | `feat(license): LicenseService + LicenseChangedEvent` | +| 14 | `b95e80a2` | `feat(license): wire LicenseService into boot order (env > file > DB)` | + +Plus three doc commits before T1 (already on branch): `0e512a3c` (spec), `e0be6a06` (spec revision), `ec51aef8` (plan). + +## What works end-to-end now + +- A signed license token can be installed via env var, file, admin REST POST, or auto-loaded from PG on boot. +- Validator rejects missing/blank `licenseId`/`tenantId`, tenant mismatch, missing `exp`, expired-past-grace, and signature failure. +- `LicenseService` mediates install/replace/revalidate, audits under `AuditCategory.LICENSE`, persists to PG, mutates `LicenseGate`, publishes `LicenseChangedEvent`. +- `LicenseGate.getState()` returns `ABSENT|ACTIVE|GRACE|EXPIRED|INVALID`; `getEffectiveLimits()` merges over defaults in ACTIVE/GRACE, defaults-only otherwise. +- Standalone `cameleer-license-minter` module produces tokens via `LicenseMinter.mint(info, privateKey)` or the `LicenseMinterCli` (with `--verify` round-trip). Confirmed NOT in the runtime dependency tree. +- PG schema: `license` table + 3 retention columns on `environments` migrated cleanly via Flyway V5. + +## What does NOT yet work + +- **No enforcement.** Nothing checks `LicenseGate.getEffectiveLimits()` yet. The default-tier caps exist as constants but no service calls `assertWithinCap`. +- **No usage reporting.** `/api/v1/admin/license/usage` does not exist. +- **No retention recompute.** `LicenseChangedEvent` is published but no listener acts on it. +- **No daily revalidation.** `LicenseService.revalidate()` exists but no scheduler calls it. +- **No metrics.** No Prometheus gauges yet. +- **`LicenseAdminController` still bypasses `LicenseService`** — `update(...)` constructs its own `LicenseValidator` inline. Task 29 fixes this. + +## Critical deviations from the plan to remember + +### 1. `AuditService` API (impacts Tasks 15, 21, 28, 29) + +The plan assumed `AuditService.record(category, action, actor, payload)`. The actual API is a concrete class with two `log(...)` overloads: + +```java +void log(String action, AuditCategory category, String target, + Map detail, AuditResult result, HttpServletRequest request) + +void log(String username, String action, AuditCategory category, String target, + Map detail, AuditResult result, HttpServletRequest request) +``` + +`LicenseService` (Task 13) uses the **explicit-username variant** with `request = null`. Use the same pattern in any new `AuditService` call sites in Tasks 15+. + +### 2. `LicenseInfo.label` JSON ingest (impacts any new fixture) + +The validator parses `label` from the JSON `label` field, not `tier`. Old fixtures that only set `"tier":"X"` produce `info.label() == null`. When writing new tests that need a non-null label, add `"label":"X"` to the JSON. + +### 3. Plan said "no change needed" for `LicenseValidatorTest` in Task 2 — wrong + +`info.tier()` accessor doesn't exist; it's `info.label()`. T2 implementer fixed this. If any later task asks you to construct `LicenseInfo` and assert on a "tier", use `label`. + +### 4. `LicenseValidator` always-failing fallback (Task 14) + +When `CAMELEER_SERVER_LICENSE_PUBLICKEY` is unset, `LicenseBeanConfig.licenseValidator()` constructs an anonymous subclass whose `validate()` always throws. The parent ctor is fed a freshly-generated **throwaway** Ed25519 public key so it accepts the input. Don't try to use a static all-zero base64 string — Ed25519 rejects the all-zero point at construction time. + +### 5. ClickHouse `ALTER TABLE … MODIFY TTL … WHERE` — Task 27 caveat + +The plan's `RetentionPolicyApplier` SQL (`ALTER TABLE … MODIFY TTL … WHERE environment = '…'`) requires ClickHouse 22.x+ for per-row TTL with WHERE. Verify against `cameleer-server-app/src/main/resources/clickhouse/init.sql` before implementing. If unsupported, fall back to global TTL = `min(licenseCap, max(env.configured))`. + +### 6. Per-test compile dependency between modules + +After modifying `cameleer-server-core`, the next `mvn -pl cameleer-server-app test` will fail compilation if you don't first run `mvn -pl cameleer-server-core install -DskipTests`. The implementer subagents already know this — make sure new ones do too. + +### 7. The previous code-review noted (still deferrable) + +- `LicenseValidator.label` is read via `root.get("label").asText()` which returns the literal string `"null"` for a JSON `null` value rather than Java `null`. Current fixtures don't trip this; defer. +- `LicenseValidator.validate(...)` is ~70 lines doing format-split + signature-verify + payload-parse. Splitting into helpers would help maintainability but no behavioral change required. + +## Batching strategy that worked + +The plan specifies one task = one commit. For mechanical plan-following tasks (T1, T7, T8, T9, T10, T12, T28, T31, T36) one implementer dispatch handles one or more tasks fine. Successful batches in this session: + +- T4 + T5 — both touch `cameleer-server-core/src/main/java/com/cameleer/server/core/license/`, T5 needs T4's types. +- T6 + T7 + T8 + T9 — all in the new `cameleer-license-minter` module, sequential. +- T10 + T11 + T12 — persistence layer (migration + repo + audit enum). + +Tasks NOT to batch (require judgment + per-task review): +- **T18-T25 wiring tasks** — each touches existing service code with potential downstream test fallout. Dispatch one at a time. +- **T26** (Environment record fields) — touches every `new Environment(...)` call site; high blast radius. +- **T27** (RetentionPolicyApplier) — ClickHouse SQL judgment + Async event handling. +- **T32, T33, T34** — integration tests, each large and independent. One at a time, allow full `mvn verify` runs. + +## Pre-existing working-tree dirt — leave alone + +These predate this work: +``` +M AGENTS.md +M CLAUDE.md +?? docs/superpowers/plans/2026-04-24-cmdk-attribute-filter.md +?? execution-api-response.json +?? overlay-screenshot.png +``` + +Subagents were instructed not to stage them. Continue that policy. + +## Suggested next-session task order + +1. **T15** — `LicenseCapExceededException` + `LicenseMessageRenderer` + `LicenseExceptionAdvice`. Pure new code, mechanical. Single commit. Use `LicenseMessageRenderer.forCap(...)` AND `forState(...)` (the latter is needed for the `/usage` endpoint in T30 — add it now even though the plan only mentions `forCap`). +2. **T16** — `LicenseEnforcer`. Single new class, depends on `LicenseGate.getEffectiveLimits()`. Add the 3-arg constructor with `AuditService` for `cap_exceeded` audit emission (used by Task 21). +3. **T17** — `LicenseUsageReader`. Single new class. Verify the `deployments.deployed_config_snapshot` JSONB shape against `DeploymentConfigSnapshot` before committing — the plan SQL assumes specific field names. +4. **T18-T20** — wire envs / apps / agents (one at a time, full IT each). +5. **T21** — wire users + cap_exceeded audit emission (this is when the enforcer's audit ctor matters). +6. **T22-T23** — wire outbound + alert rules (one at a time). +7. **T24** — wire compute caps (DeploymentExecutor PRE_FLIGHT). Largest single wiring task — the IT will need a real Docker flow stub. +8. **T25** — wire retention + jar caps. +9. **T26** — Environment record retention fields. High blast radius. Use gitnexus_context first. +10. **T27** — RetentionPolicyApplier (handle ClickHouse caveat). +11. **T28** — LicenseRevalidationJob. +12. **T29-T31** — REST surface + metrics. +13. **T32-T34** — integration tests. +14. **T35** — SchemaBootstrapIT extension + OpenAPI regen. +15. **T36** — `.claude/rules/*.md` updates. + +## Key files reference + +- Plan: `docs/superpowers/plans/2026-04-25-license-enforcement.md` +- Spec: `docs/superpowers/specs/2026-04-25-license-enforcement-design.md` +- Domain: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/` +- App license layer: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/` +- Boot config: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` +- PG migration: `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` +- Minter: `cameleer-license-minter/` +- AuditService: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditService.java` + +## Verification commands at handoff + +```bash +git log --oneline b95e80a2 -1 # confirm last commit +git log --oneline ec51aef8..b95e80a2 --no-merges # 14 commits in range +mvn -pl cameleer-server-core install -DskipTests # publish core to local repo +mvn -pl cameleer-server-core test # 122 tests, all pass +mvn -pl cameleer-license-minter test # 7 tests, all pass +mvn -pl cameleer-server-app test -DskipITs # 230 tests, all pass +mvn -pl cameleer-server-app verify -Dit.test=PostgresLicenseRepositoryIT,SchemaBootstrapIT +mvn dependency:tree -pl cameleer-server-app | grep license-minter # MUST be empty +```