Story CP-007bis: Module Gating — Cache Invalidation & TTL Security Ceiling¶
Module: control-plane
Status: 🟡 DRAFT — awaiting human PO review and conversion to a numbered story
Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite
Priority: 7 (delta on CP-007)
Depends on: CP-007 (creates TenantModule, ModuleGatingService, ModuleGatingInterceptor, Redis + Caffeine caches), CP-002 (publishes TenantStatusChangedEvent)
Estimated complexity: S
Drafted by: Architecture session 2026-05-02 (docs/qr-006-access-control-clarification)
Why this story exists (gap analysis vs CP-007 / CP-020)¶
CP-007 establishes the module gating infrastructure with caching:
"Cached in Redis (TTL: 5 min) with Caffeine fallback (TTL: 1 min)."
CP-020 adds suspended-tenant write denial. Neither story specifies how the cache is invalidated when a tenant's status or a module's activation status changes. The default 5-minute TTL is a security risk for the suspension path:
- Tenant becomes SUSPENDED at T₀ (stops paying, admin suspends, or grace period expires).
TenantStatusChangedEvent(SUSPENDED)is published.- Until T₀ + 5 min,
ModuleGatingService.isModuleActive(tenantId, "QRCONTR")continues to return cachedACTIVEfrom Redis. - The non-paying tenant retains illicit write access for up to 5 minutes.
The same gap exists for ModuleActivated / ModuleDeactivated: a deactivated module remains "active" in cache, and a newly-activated module remains "inactive" in cache. The deactivation case is the security-relevant one.
This story closes the gap by adding event-driven cache flushing and lowering the TTL ceiling as a defensive measure.
Objective¶
Ensure that any change to a tenant's operability (status) or module subscription is reflected in the module gating cache within ≤1 second of the corresponding application event being published. Add a defensive TTL ceiling so even if the listener fails to fire, the staleness window is bounded.
Backend Scope¶
New Component: ModuleGatingCacheInvalidator¶
A @Component in the tenant Modulith module (same package as ModuleGatingService).
Responsibilities:
- Listens to TenantStatusChangedEvent → flushes ALL Redis + Caffeine entries for that tenantId (key pattern module:{tenantId}:*).
- Listens to ModuleActivated and ModuleDeactivated → flushes the single cache entry module:{tenantId}:{moduleCode} in both Redis and Caffeine.
- Listener annotation: @ApplicationModuleListener (Spring Modulith — guaranteed delivery via EVENT_PUBLICATION table per ADR-21).
- Idempotent: re-running on the same event has no adverse effect.
- Logs at INFO level for every flush (tenantId, eventType, affectedKeys count) to support audit/post-mortem.
Modification: ModuleGatingService¶
TTL ceiling reduction: - Redis TTL: lower from 5 minutes → 2 minutes (defensive ceiling — listener should flush in ms; TTL is the safety net). - Caffeine fallback TTL: keep at 1 minute (already short enough).
Rationale: even if the event publication table fails or the listener errors, the staleness window is bounded to 2 minutes for security-critical state transitions, not 5.
Test Support¶
- Add
ModuleGatingCacheTestSupportintest-supportmodule — exposesflushAll()andassertCacheEmpty(tenantId)for integration tests.
Frontend Scope¶
N/A — Backend-only slice.
Acceptance Criteria¶
AC-CP007bis-001: TenantStatusChangedEvent flushes module gating cache
Given tenant T has TenantModule(QRCONTR, ACTIVE) cached in Redis and Caffeine
And tenant T's status transitions from ACTIVE to SUSPENDED
When TenantStatusChangedEvent(tenantId=T, oldStatus=ACTIVE, newStatus=SUSPENDED) is published
Then within 1 second, the cache entries module:T:* are removed from Redis
And the cache entries module:T:* are removed from Caffeine
And the next call to isModuleActive(T, any) re-reads from the database
AC-CP007bis-002: ModuleDeactivated flushes single module cache entry
Given tenant T has TenantModule(EXPMGT, ACTIVE) cached
When ModuleDeactivated(tenantId=T, moduleCode=EXPMGT) is published
Then within 1 second, module:T:EXPMGT is removed from Redis and Caffeine
And module:T:QRCONTR (other modules) remains untouched
AC-CP007bis-003: ModuleActivated flushes single module cache entry
Given tenant T does not have TenantModule(PAYROL, ACTIVE) — cache holds INACTIVE result
When ModuleActivated(tenantId=T, moduleCode=PAYROL) is published
Then within 1 second, module:T:PAYROL is removed from Redis and Caffeine
And the next call to isModuleActive(T, PAYROL) reads ACTIVE from DB
AC-CP007bis-004: TTL ceiling enforced
Given a fresh write to the module gating cache for module:T:QRCONTR=ACTIVE
When 2 minutes 1 second elapse with no events
Then the Redis entry has expired
And the next call to isModuleActive(T, QRCONTR) re-reads from the database
AC-CP007bis-005: Cache invalidator is idempotent
Given module:T:QRCONTR has already been flushed
When TenantStatusChangedEvent for tenant T is published a second time
Then the listener completes successfully without errors
And the cache remains empty (no resurrected entries)
Multi-Tenant Considerations¶
- Cache keys are tenant-scoped (
module:{tenantId}:*) — flushing one tenant never affects another. - For Profile C / D (DB-per-tenant, BYO-DB): cache invalidation pattern is identical because the cache is in the platform process memory + Redis, not in tenant DBs.
- Open question (forwarded to OQ-20 in PROGRESS.md): for Profiles C/D, should the connection pool also be marked read-only on SUSPENDED? Out of scope for this story — covered separately when those profiles activate.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §6 Spring Boot 4 / Spring Modulith (use@ApplicationModuleListener)docs/architecture/platform-vision-arch.md— §12.5 (Tenant Isolation Security Layers), §15 (Caching architecture)- ADR-21 — Audit trail integration via Application Events (same delivery guarantee mechanism applies here)
Testing Requirements¶
Unit Tests¶
ModuleGatingCacheInvalidator: receivesTenantStatusChangedEvent→ calls Redis + Caffeine flush methods with correct key patternModuleGatingCacheInvalidator: receivesModuleDeactivated→ flushes single entryModuleGatingCacheInvalidator: idempotent on repeated events
Integration Tests¶
- End-to-end: write cache → publish
TenantStatusChangedEvent→ verify cache empty within 1 second (useAwaitility) - End-to-end: write cache → wait 2 min 1 sec → verify TTL expiry
- Tenant isolation: flush tenant A cache, verify tenant B cache untouched
- Failure injection: simulate Redis unavailable during flush → fallback to Caffeine flush succeeds, error logged
What QA Will Validate¶
- Manual: suspend a tenant in admin console; within 2 seconds, verify the tenant's QRCONTR endpoints return 403
TENANT_SUSPENDED(not stale 200 OK).
Out of Scope¶
- Pool-level read-only enforcement for Profile C/D — see OQ-20 in PROGRESS.md.
- Cache invalidation for non-gating caches (permissions, country config) — owned by other ADRs.
- Distributed cache coherence beyond Redis pub/sub — single Redis instance is MVP-sufficient.
Definition of Done¶
- [ ]
ModuleGatingCacheInvalidatorimplemented with@ApplicationModuleListenerfor the three event types - [ ] Redis TTL ceiling reduced from 5 min → 2 min in
ModuleGatingService - [ ] Caffeine fallback TTL unchanged (1 min)
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing (using Awaitility for the 1-second assertion window)
- [ ] Multi-tenant isolation verified (tenant A flush ≠ tenant B flush)
- [ ] No Java raw types
- [ ] Audit log entry on every flush
- [ ] Standards guidelines updated if new pattern emerges
- [ ] PROGRESS.md OQ-19 closed and decision documented
Notes for the Human PO¶
This file is a draft plan, not an active story in the index.md story list. Convert it to a numbered story when ready (suggested ID: CP-031 if following the next-free-number rule, or rename to keep the bis convention if you prefer attaching it explicitly to CP-007). When converting:
- Rename file to
CP-031.md(or keep asCP-007bis.md— your call on the naming pattern). - Add to
docs/stories/control-plane/index.mdwith dependencies CP-007, CP-002. - Update Implementation Order section.
- Close OQ-19 in
docs/architecture/PROGRESS.mdonce the story is implemented and merged.