Aller au contenu

PD-278 — Agent Developer Report: dip-rate-limit (C8)

Agent: agent-developer Module: dip-rate-limit Date: 2026-03-01 Status: DELIVERED


1. Scope

Implementation of DisseminationRateLimitGuard — a NestJS guard enforcing per-actor rate-limiting and daily quota for SEALED -> DIP transitions.

File delivered: src/modules/documents/guards/dissemination-rate-limit.guard.ts


2. Code Contract Compliance

Interfaces

Interface Status File
DisseminationRateLimitGuard Implemented src/modules/documents/guards/dissemination-rate-limit.guard.ts

Invariants

Invariant Status Implementation Detail
INV-278-02: rate-limit 60 req/min + quota 1000 req/jour par acteur (configurable) PASS Dual Redis counter: dip:rate:{actorId} (60s TTL) + dip:quota:{actorId}:{YYYY-MM-DD} (86400s TTL). Defaults from DisseminationConfig via ConfigService.get('dissemination.rateLimitPerMinute') and ConfigService.get('dissemination.quotaPerDay').
Fail-closed: si Redis indisponible -> 503 E-503-REDIS PASS Any Redis error (non-HttpException) caught in canActivate() throws HttpException(503) with error_code: 'E-503-REDIS'. No fallback, no bypass.
Rejet 429 declenche audit DOCUMENT_DISSEMINATION_DENIED via exception filter C11 PASS (by design) Guard throws HttpException(429) with error_code: 'E-429-RATE-LIMIT'. The DisseminationAuditExceptionFilter (C11) applied on the controller captures 429 and persists audit synchronously. No audit logic in the guard itself (separation of concerns).

Forbidden Rules

Forbidden Status Verification
APP_GUARD global PASS Guard is @Injectable(), not registered globally. Intended to be used via @UseGuards(DisseminationRateLimitGuard) on the controller (scope controller).
Fail-open si Redis down PASS catch block in canActivate(): non-HttpException errors always throw 503. No return true fallback.
Rate-limit par document_id PASS Key pattern: dip:rate:{actorId} and dip:quota:{actorId}:{date}. Actor identity extracted from request.user.sub ?? request.user.id. No reference to document_id.

3. Architectural Decisions

AD-1: HttpException instead of DisseminationError

  • Decision: Use HttpException from @nestjs/common directly instead of importing DisseminationError from ../dto/dissemination-error.dto.ts.
  • Rationale: The DTO module (dip-dto) is delivered by another agent and the file does not exist yet at implementation time. Using HttpException directly ensures the guard compiles independently with zero cross-agent dependencies, following the same pattern as LegalRateLimitGuard (PD-81).
  • Alternatives considered: (1) Import DisseminationError — would break compilation until DTO agent delivers. (2) Create a minimal stub DTO — violates agent isolation (hors perimetre).
  • Trade-offs: Response body uses string literal 'E-429-RATE-LIMIT' instead of typed enum. The controller/filter integration (C10/C11) will use exception.getResponse()['error_code'] for dispatch. When DTO module is delivered, the guard CAN be migrated to DisseminationError — refactoring is mechanical and non-breaking.

AD-2: Redis config from storage.redis (not REDIS_URL)

  • Decision: Instantiate Redis from storage.redis.host/port/password/db via ConfigService, not from REDIS_URL.
  • Rationale: The project's canonical Redis config is in src/config/storage.config.ts under storage.redis.*. The LegalRateLimitGuard uses REDIS_URL which is a different pattern specific to the legal-pre module. Using storage.redis.* is consistent with RateLimitService (PD-23) and the broader project configuration.
  • Alternatives considered: REDIS_URL string (LegalRateLimitGuard pattern), @InjectRedis() decorator (if BullMQ module exposes it).
  • Trade-offs: Slightly more verbose constructor, but consistent with the majority of Redis consumers in the codebase.

4. Implementation Details

4.1 Redis Key Strategy

Key Pattern TTL Purpose
dip:rate:{actorId} 60s Per-minute sliding window counter
dip:quota:{actorId}:{YYYY-MM-DD} 86400s Daily quota counter, resets at UTC midnight
  • INCR is atomic — first call creates key with value 1
  • EXPIRE set on count === 1 (first request in window)
  • Daily key includes date suffix for natural reset at UTC midnight

4.2 Error Response Format

Follows PD-279 pattern:

{
  "error_code": "E-429-RATE-LIMIT",
  "message": "Rate limit or daily quota exceeded",
  "details": {
    "limit_type": "per_minute",
    "current": 61,
    "max": 60,
    "retry_after": 45
  }
}

For Redis failure:

{
  "error_code": "E-503-REDIS",
  "message": "Service temporarily unavailable",
  "details": { "reason": "redis_unavailable" }
}

4.3 Actor Identity Extraction

The guard extracts actor_id from request.user.sub (JWT claim) or request.user.id (fallback). If neither is present (which should not occur given JwtAuthGuard runs first), the guard fail-closes with 429.

4.4 Compilation Status

  • npx tsc --noEmit: 0 errors on the guard file
  • No errors introduced in the broader project compilation

5. Integration Points

5.1 Dependencies (consumed)

Dependency Source Usage
ConfigService @nestjs/config Read dissemination.rateLimitPerMinute, dissemination.quotaPerDay, storage.redis.*
Redis (ioredis) ioredis Atomic INCR + EXPIRE for rate limiting

5.2 Consumers (downstream)

Consumer Module Integration
DisseminationController (C10) dip-controller @UseGuards(DisseminationRateLimitGuard) on F1 endpoint only
DisseminationAuditExceptionFilter (C11) dip-exception-filter Captures 429 thrown by this guard to persist DOCUMENT_DISSEMINATION_DENIED audit synchronously
DocumentsModule documents Guard must be registered as provider in the module

5.3 Module Registration (required by consuming agent)

The guard must be added to DocumentsModule.providers:

providers: [
  // ... existing providers
  DisseminationRateLimitGuard,
]
And ConfigModule.forFeature(disseminationConfig) must be in the module imports.


6. Test Coverage Mapping

Test ID Assertion Guard Responsibility Test Level
TC-ERR-13 Rejet 429 + audit DENIED quand quota/debit depasse Guard throws 429; filter audits Integration
TC-INV-02 Batterie gardes incluant rate-limit Guard is one of the 6 sequential guards Integration
TC-INV-04 Audit refus securite 429 Guard throws; filter captures and audits synchronously Integration

Suggested Unit Test Cases for dissemination-rate-limit.guard.spec.ts

TC-ERR-13-A: should allow requests within per-minute rate limit
TC-ERR-13-B: should reject with 429 when per-minute rate limit exceeded
TC-ERR-13-C: should allow requests within daily quota
TC-ERR-13-D: should reject with 429 when daily quota exceeded
TC-ERR-13-E: should fail-closed with 503 when Redis unavailable (E-503-REDIS)
TC-ERR-13-F: should set TTL on first request in per-minute window
TC-ERR-13-G: should set TTL on first request in daily quota window
TC-ERR-13-H: should use actor_id (not document_id) for rate-limit key
TC-ERR-13-I: should reject with 429 when no actor identity (fail-closed)
TC-ERR-13-J: should include retry_after in 429 response details (per-minute)
TC-ERR-13-K: should include retry_after in 429 response details (daily quota)
TC-ERR-13-L: should read config from dissemination.rateLimitPerMinute and dissemination.quotaPerDay

7. Hypotheses

ID Hypothesis Status
H-RATE-01 JwtAuthGuard executes before DisseminationRateLimitGuard so request.user is populated Assumed — guard order in @UseGuards(JwtAuthGuard, AuthorizationGuard, DisseminationRateLimitGuard) on controller ensures this
H-RATE-02 storage.redis.* config is available via ConfigService at guard instantiation time Confirmed — storage.config.ts loaded in AppModule imports
H-RATE-03 dissemination.* config is available via ConfigService Requires ConfigModule.forFeature(disseminationConfig) in DocumentsModule imports
H-RATE-04 Redis INCR returns number (not string) via ioredis Confirmed — ioredis coerces Redis integer replies to JS numbers

8. Risks and Debt

ID Risk/Debt Severity Mitigation
R-01 Guard uses HttpException instead of typed DisseminationError LOW Mechanical refactoring once DTO module delivered. Response body already follows contractual format.
R-02 Redis connection created in constructor (not lazy) LOW Consistent with LegalRateLimitGuard pattern. If Redis is down at startup, guard instantiation succeeds but first canActivate() call will fail-closed (503).
R-03 INCR + EXPIRE are two separate Redis commands (not MULTI/EXEC) LOW Same pattern as LegalRateLimitGuard. Race condition window is negligible (< 1ms). Worst case: key without TTL leaks — mitigated by Redis maxmemory-policy allkeys-lru.
R-04 Daily quota key uses Date().toISOString().slice(0,10) for date LOW Server time must be NTP-synchronized (spec §5.10). No timezone ambiguity since ISO 8601 slice always returns UTC date.

9. Files Modified

File Action Lines
src/modules/documents/guards/dissemination-rate-limit.guard.ts CREATED 173

10. Verification Checklist

  • Implements CanActivate interface (NestJS guard)
  • Per-minute rate limit: Redis INCR + EXPIRE, key dip:rate:{actorId}
  • Daily quota: Redis INCR + EXPIRE, key dip:quota:{actorId}:{YYYY-MM-DD}
  • Config from DisseminationConfig via ConfigService (not hardcoded)
  • Fail-closed on Redis unavailability (503 E-503-REDIS)
  • No fail-open path
  • Rate-limit by actorId, not document_id
  • Not registered as APP_GUARD (scoped to controller via @UseGuards)
  • Error response follows PD-279 format (error_code, message, details)
  • TypeScript compilation: 0 errors
  • No cross-agent file dependencies (self-contained)