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