capitalisation: triage 95_a_capitaliser + création domaine infra

Triage des 27 propositions du buffer de capitalisation (skill
capitalisation-triage), avec vérification des doublons contre la base.

Intégré dans knowledge/ (23 entrées):
- backend: redis (compensation incrBy non-atomique), nestjs (injection
  cassée sous tsx watch; guard write mode dégradé), async (test rollback
  pipeline multi-fichiers), contracts (idempotence POST), auth (disclosure
  comptes soft-deleted), prisma (index partial soft-delete), llm-providers
  (nouveau: OAuth vs API key, prompt caching).
- frontend: tests (garde-fous parking Later), navigation (fichiers
  non-route sous src/app Expo Router), general (type client vs payload
  backend), state (fallback catch-all mapping DB→UI).
- workflow: story-tracking (statut BMAD vs narratif obsolète).
- product: general (nouveau: doc feature store sans UI).
- infra: NOUVEAU DOMAINE (traefik, tailscale, docker, docker-networking,
  reverse-proxy-paths, sidecar tailscale) + 00_INDEX.md.

Autres:
- 90_debug_et_postmortem.md: post-mortem réseau Docker partagé hors compose.
- Rejeté 3 doublons (types enum contracts, getter PrismaService, $transaction).
- Buffer 95_a_capitaliser.md purgé et restauré à son état initial.
- _projects.conf: MAJ statuts epics + ajout app-rl799.
This commit is contained in:
MaksTinyWorkshop
2026-06-25 10:31:22 +02:00
parent 1c876309f1
commit ef24d85d57
31 changed files with 1042 additions and 27 deletions
+3 -2
View File
@@ -9,12 +9,13 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `auth.md` | Auth, sessions, tokens, erreurs API, corrélation | Format erreur standardisé, middleware requestId, anti-énumération, token usage unique, autorisation interne, opérations atomiques |
| `contracts.md` | Contrats API, Zod, error codes, HTTP sémantique | Contracts-First/Zod-Infer/No-DTO, error codes comme contrat, HTTP 200 payload métier |
| `contracts.md` | Contrats API, Zod, error codes, HTTP sémantique | Contracts-First/Zod-Infer/No-DTO, error codes comme contrat, HTTP 200 payload métier, idempotence POST = retour ressource existante |
| `prisma.md` | Prisma, DB, migrations, pagination | Soft delete, pagination cursor, idempotency key, P2002 unique, Decimal sérialisation, migration manuelle P3014, filtrage métier dans service |
| `stripe.md` | Stripe, paiements, webhooks entrants, subscriptions | Provider-Strategy, metadata subscription_data, parsing webhook unique, restauration achats, Trial vs Paid |
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique |
| `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement |
| `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe |
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL |
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL, test de rollback pipeline multi-fichiers atomique |
| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable, ordre canonique des gates HTTP, délégation agrégat → endpoint agrégé, anti-énumération DELETE 204, lazy init memoizé, cap LRU par-user, convention dot-notation audit, whitelist explicite audit, singleton DB config, invalidation cache avant mutation, pipeline CI/CD GitHub Actions → VPS |
| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné, test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe |
| `llm-providers.md` | Fournisseurs LLM, auth, coûts | OAuth consumer ≠ API key, prompt caching system prompt stable, budget cap fournisseur |
+27
View File
@@ -251,3 +251,30 @@ export const listRecentXxxForMember = async (
```
L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet.
---
<a id="pattern-test-rollback-pipeline-multi-fichiers"></a>
## Pattern : Test de rollback pour pipelines multi-fichiers atomiques
- Objectif : garantir qu'un pipeline qui écrit N fichiers avec rollback nettoie réellement l'état partiel quand le Nème échoue.
- Contexte : opération qui persiste plusieurs artefacts (ex : variantes/dérivés d'une image, fichiers d'un export) en gardant la liste des chemins déjà écrits pour pouvoir les supprimer en cas d'échec.
- Quand l'utiliser : tout pipeline « tout ou rien » sur N écritures de fichiers avec compensation manuelle (pas de transaction native).
- Quand l'éviter : écriture d'un seul fichier, ou stockage qui offre une vraie transactionnalité.
- Avantage :
- couvre le cas limite réel (échec en cours de pipeline) plutôt que le seul chemin nominal
- détecte une compensation incomplète (fichiers orphelins) ou excessive (suppression d'un fichier non écrit par ce pipeline)
- Limites / vigilance :
- le test doit être mis à jour quand N change (ajout d'un variant) pour couvrir le nouveau cas limite
- Validé le : 03-04-2026
- Contexte technique : Node.js / écriture fichiers — app-template-resto story 4.3
### Règle
Tout pipeline qui écrit N fichiers avec rollback (suppression des déjà-écrits si un échec survient) doit avoir un test unitaire couvrant le cas **« N-1 fichiers écrits + le Nème échoue »**. Ce test vérifie :
- que les N-1 fichiers déjà écrits sont **exactement** supprimés (ni plus, ni moins) ;
- que la phase de finalisation (`finalize()` ou équivalent) n'est **pas** appelée ;
- que l'erreur est bien propagée à l'appelant.
Quand le nombre d'artefacts change (ajout d'un variant), mettre à jour ce test pour couvrir le nouveau N.
+30 -2
View File
@@ -2,10 +2,10 @@
title: Backend — Patterns : Contracts
domain: backend
bucket: patterns
tags: [contracts, zod, api, error-codes, monorepo]
tags: [contracts, zod, api, error-codes, monorepo, idempotence, http-semantics]
applies_to: [analysis, implementation, review, architecture]
severity: high
validated_on: 2026-04-07
validated_on: 2026-06-25
source_projects: [app-alexandrie, RL799_V2]
---
@@ -150,6 +150,34 @@ return res.status(200).json({
---
<a id="pattern-idempotence-post-ressource-existante"></a>
## Pattern : Idempotence POST = retour de la ressource existante
- Objectif : garantir qu'un POST dont l'AC demande l'idempotence produit le **même résultat quel que soit le nombre d'appels**.
- Contexte : endpoint de création où une duplication est détectable (ressource déjà créée pour le même sujet, ex : demande d'export, déclenchement de job unique). Complément du pattern « HTTP 200 + payload métier » ci-dessus, appliqué au cas duplication.
- Quand l'utiliser : POST déclaré idempotent (retries client, double-tap mobile, rejeu réseau).
- Quand l'éviter : création stricte où une duplication est une vraie erreur métier que l'appelant doit traiter explicitement.
- Validé le : 13-04-2026
- Contexte technique : NestJS / contrat API — app-alexandrie story 9.2
### Règle
Un POST idempotent **retourne la ressource existante** (HTTP 200 / 201) quand une duplication est détectée, sans lever d'erreur. Un `409 CONFLICT` est un comportement de **blocage**, pas d'idempotence : il force l'appelant à gérer un cas d'erreur et casse le contrat « N appels = 1 résultat ».
```typescript
// ✅ IDEMPOTENT — retourne la ressource existante
if (existing) return this.serialize(existing);
// ❌ NON-IDEMPOTENT — lève une erreur de blocage
if (existing) throw new HttpException({ error: { code: 'ALREADY_EXISTS' } }, 409);
```
### Côté client
Dédupliquer par `id` avant d'insérer dans la liste/store local, pour éviter les doublons en cas de race condition (deux requêtes parallèles recevant la même ressource).
---
<a id="pattern-coherence-result-repository"></a>
## Pattern : Cohérence du pattern Result dans un repository
@@ -0,0 +1,59 @@
---
title: "Backend — Patterns : Fournisseurs LLM"
domain: backend
bucket: patterns
tags: [llm, anthropic, openai, api-key, oauth, prompt-caching, couts]
applies_to: [analysis, implementation, architecture]
severity: medium
validated_on: 2026-06-25
source_projects: [_Assistant_Cuisine]
---
# Backend — Patterns : Fournisseurs LLM
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
---
<a id="pattern-oauth-consumer-vs-api-key"></a>
## OAuth abonnement consumer ≠ API key — ne pas mélanger les deux usages
Tous les fournisseurs LLM (Anthropic, OpenAI, Google) séparent strictement deux mondes :
| Type | Auth | Usage prévu | Coût |
|---|---|---|---|
| **Abonnement consumer** (Claude Pro/Max, ChatGPT Plus) | OAuth via app officielle | usage interactif personnel via clients officiels (desktop, web, plugin IDE) | forfait mensuel |
| **API platform** | API key | usage programmatique, multi-users, services tiers, prod | pay-per-token |
### Règle
- Ne **jamais** utiliser les credentials OAuth d'un abonnement consumer pour servir une application à plusieurs utilisateurs. Anthropic l'interdit explicitement (ToS) ; les autres le tolèrent moins ouvertement mais le principe est identique. Risque concret : bannissement du compte si détecté.
- Pour toute application applicative (chatbot, intégration produit), prendre une **API key séparée**, indépendante de l'abonnement consumer.
---
<a id="pattern-prompt-caching-system-prompt-stable"></a>
## Prompt caching obligatoire dès qu'un gros system prompt est stable
Tout système ayant un system prompt volumineux et stable (knowledge base, format de sortie, persona) DOIT activer le prompt caching côté fournisseur. La portion cachée est facturée à une fraction du tarif normal (ordre de grandeur : ~10 %), ce qui divise le coût input par 5 à 10 sur un usage où le system prompt domine.
```ts
// OpenAI : promptCacheKey via provider metadata
streamText({
model: openai("gpt-4.1-mini"),
system: bigStaticPrompt,
experimental_providerMetadata: {
openai: { promptCacheKey: "myapp-system-v1" },
},
});
// Anthropic : marquage explicite des blocs cacheables dans messages (cache_control)
```
### Conséquences sur les coûts
- Le **coût marginal** d'un nouvel utilisateur est quasi nul tant que le system prompt domine le volume de tokens.
- Pour un usage léger (quelques conversations/jour, prompt caching actif), prévoir un budget mensuel de l'ordre de quelques euros.
- Toujours poser un **budget cap** côté console fournisseur pour éviter toute dérive.
---
+4 -4
View File
@@ -8,12 +8,12 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `auth.md` | Auth, sessions, guards, accès | AuthN/AuthZ dispersée, guard global manquant, null-check request.user, AdminRoleGuard sans @RequireAdminRole, GET sans contrôle accès, cookie après révocation, mock session sans expiresAt, buildApp partagé e2e, champ absent JWT, email login vs contact |
| `auth.md` | Auth, sessions, guards, accès | AuthN/AuthZ dispersée, guard global manquant, null-check request.user, AdminRoleGuard sans @RequireAdminRole, GET sans contrôle accès, cookie après révocation, mock session sans expiresAt, buildApp partagé e2e, champ absent JWT, email login vs contact, disclosure comptes soft-deleted dans login() |
| `contracts.md` | Contrats, validation, codes erreur | Contrats implicites, erreurs non standardisées, duplication constantes, schema orphelin, code erreur générique 409, ForbiddenException pour validation, process.env direct, statut métier non propagé |
| `prisma.md` | Prisma, DB, transactions, migrations | @unique nullable, TOCTOU transaction, OR tenantId null, nextOrder race condition, tenantId sans FK, schema divergence spec, getter manquant, init module build, clearAllMocks imbriqué, cursor non validé, enum-like String, migration manuelle hors git, relation 1:1 sans unique |
| `prisma.md` | Prisma, DB, transactions, migrations | @unique nullable, TOCTOU transaction, OR tenantId null, nextOrder race condition, tenantId sans FK, schema divergence spec, getter manquant, init module build, clearAllMocks imbriqué, cursor non validé, enum-like String, migration manuelle hors git, relation 1:1 sans unique, index partial soft-delete (perf) |
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end, list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing |
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète |
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h |
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète, guard multi-statut READ_METHODS, bootstrap OK mais injection cassée (tsx watch), guard écriture mode dégradé bloque le support |
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h, compensation incrBy non-atomique (quota fantôme) |
| `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags, dossiers `_*` exclus du routing App Router |
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch, valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level, dérive DTO liste vs détail, notification linkUrl rôle-aware, matrice documentée vs code, format `User.id` mixte, Web Push topic > 32 chars, lib npm types non embarqués, form HTML POST dans un mail, env vars frontend-facing fail-fast |
| `tests.md` | Isolation des tests d'intégration | `vi.stubEnv` sans restauration, `maxWorkers: 1` masque l'isolation, flakiness inter-fichiers DB partagée |
+39 -3
View File
@@ -2,10 +2,10 @@
title: Backend — Risques & vigilance : Auth
domain: backend
bucket: risques
tags: [auth, guards, request-user, sessions, admin]
tags: [auth, guards, request-user, sessions, admin, enumeration, soft-delete]
applies_to: [implementation, review, debug]
severity: high
validated_on: 2026-04-07
validated_on: 2026-06-25
source_projects: [app-alexandrie, RL799_V2]
---
@@ -458,4 +458,40 @@ Quand on prévoit de supprimer un flag auth profondément câblé :
**Anti-pattern** : déprécier en douceur en gardant le flag avec un commentaire `// @deprecated` sans supprimer les usages. Le code mort s'accumule, les futurs devs hésitent à le nettoyer ("pourquoi c'est encore là ?"), la dépréciation ne se finit jamais.
- Contexte technique : auth / refactor schema — RL799_V2 28-04-2026
- Contexte technique : auth / refactor schema — RL799_V2 28-04-2026
---
<a id="risque-disclosure-comptes-soft-deleted-login"></a>
## Information disclosure sur comptes soft-deleted dans `login()`
### Risques
- Retourner un code d'erreur distinct (ex : `ACCOUNT_DELETED`) pour les comptes supprimés dans `login()` permet l'**énumération** : un attaquant distingue « compte supprimé » de « identifiants invalides » par email.
- Complément du pattern anti-énumération (cf. `pattern-...anti-énumération` dans `patterns/auth.md`) appliqué au cas soft-delete.
### Symptômes
- `login()` lève `ACCOUNT_DELETED` au lieu de `INVALID_CREDENTIALS` pour un compte soft-deleted
- L'existence (et le statut) d'un compte fuite via la réponse d'authentification
### Bonnes pratiques / mitigations
```typescript
// ❌ DANGEREUX — révèle l'existence d'un compte supprimé
if (user.deletedAt !== null) {
throw new UnauthorizedException({ error: { code: 'ACCOUNT_DELETED' } });
}
// ✅ CORRECT — même code que des identifiants invalides
if (user.deletedAt !== null) {
throw new UnauthorizedException({
error: { code: 'INVALID_CREDENTIALS', message: 'Email ou mot de passe invalide.' },
});
}
```
- **Règle** : dans `login()`, toujours répondre `INVALID_CREDENTIALS` pour un compte soft-deleted — jamais un code spécifique.
- **Nuance** : un code `ACCOUNT_DELETED` reste acceptable dans un flux `exchange()` OAuth, où le provider a déjà confirmé l'identité (pas d'énumération possible côté attaquant).
- Contexte technique : auth / soft-delete / anti-énumération — app-alexandrie 13-04-2026
+49 -2
View File
@@ -2,10 +2,10 @@
title: Backend — Risques & vigilance : NestJS
domain: backend
bucket: risques
tags: [nestjs, controllers, guards, providers, review]
tags: [nestjs, controllers, guards, providers, review, injection, tsx-watch, mode-degrade]
applies_to: [implementation, review, debug]
severity: high
validated_on: 2026-03-30
validated_on: 2026-06-25
source_projects: [app-alexandrie]
---
@@ -144,3 +144,50 @@ if (user.sessionStatus === 'BLOCKED') throw new HttpException(...);
- **Règle** : définir une whitelist explicite des writes autorisés même en état bloqué.
- Contexte technique : NestJS / guard statut — app-alexandrie 30-03-2026
---
<a id="risque-bootstrap-ok-injection-cassee-tsx-watch"></a>
## Bootstrap NestJS OK mais injection runtime cassée sous `tsx watch`
### Risques
- En dev, un serveur NestJS peut afficher `Nest application successfully started` et exposer ses routes, tout en cassant à la **première requête** : des dépendances injectées (`Reflector`, services consommés par les guards globaux) arrivent à `undefined` au runtime.
- Le symptôme initial ressemble à un problème réseau/mobile alors que la cause réelle est l'injection backend.
### Symptômes
- `GET /` ou une route d'auth répond `500` alors que le bootstrap est nominal
- Stack runtime du type `Cannot read properties of undefined (reading 'getAllAndOverride')`
- Les guards globaux (`AuthGuard`, guards basés sur `Reflector`) sont les premiers à tomber
### Bonnes pratiques / mitigations
- Ne pas conclure trop vite à un problème réseau dès que le port écoute : traiter « le port écoute » et « les requêtes fonctionnent » comme **deux validations distinctes**.
- Tester immédiatement `GET /`, l'endpoint OpenAPI et une route publique métier, puis lire la stack runtime **après le premier hit** (pas seulement les logs de bootstrap).
- Si un lanceur `tsx watch` est utilisé avec NestJS, vérifier explicitement la compatibilité avec l'injection runtime ; en cas de doute, expliciter les injections critiques avec `@Inject(...)` sur guards et services exposés dès le premier hit.
- Contexte technique : NestJS / tsx watch / injection — app-alexandrie 01-04-2026
---
<a id="risque-writemodeguard-bloque-endpoints-support"></a>
## Guard d'écriture en mode dégradé bloque les endpoints de support
### Risques
- Un guard global qui refuse toutes les écritures (`POST/PUT/PATCH/DELETE`) quand un service critique est down (ex : Redis) bloque aussi les endpoints dont l'utilité est **maximale en panne** : soumission de ticket de support, feedback, remontée de logs d'erreur client.
- Cas typique : un endpoint de ticketing (`POST /support/...`) devient inaccessible précisément au moment où l'utilisateur veut signaler le dysfonctionnement qu'il subit.
### Symptômes
- Soumission de ticket de support impossible quand le service de quota/cache est indisponible
- Le guard rejette des routes qui devraient rester ouvertes en mode dégradé
### Bonnes pratiques / mitigations
- Lors de l'ajout d'un guard d'écriture en mode dégradé, **recenser explicitement les endpoints critiques qui doivent rester disponibles** : support, feedback, logs d'erreur client.
- Whitelister ces préfixes dès la création du guard — pas lors de la code review d'une story ultérieure. Exemple de whitelist : `/auth`, `/billing/.../webhooks`, `/support`.
- **Why** : un service de ticketing bloqué quand le backend dysfonctionne est contre-productif — l'utilisateur ne peut pas signaler le problème qu'il est en train de subir.
- Contexte technique : NestJS / guard mode dégradé — app-alexandrie 01-04-2026
+32 -2
View File
@@ -2,10 +2,10 @@
title: Backend — Risques & vigilance : Prisma
domain: backend
bucket: risques
tags: [prisma, transactions, tenant, schema, race-condition]
tags: [prisma, transactions, tenant, schema, race-condition, index, soft-delete, performance]
applies_to: [implementation, review, debug, architecture]
severity: high
validated_on: 2026-04-07
validated_on: 2026-06-25
source_projects: [app-template-resto, app-alexandrie, RL799_V2]
---
@@ -619,3 +619,33 @@ const resolveDbUrl = (): string | undefined => {
**Règle générale** : toute stratégie template-based doit auditer le chemin du `DB_URL` à travers les sub-processes de bootstrap. Le bootstrap ouvre une connexion sur la template, mais le seed transitif exécuté via un sub-process peut être sujet à des transformations agressives du DSN qui le redirigent ailleurs.
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026
---
<a id="risque-index-partial-soft-delete-perf"></a>
## Index partial sur colonnes soft-delete (nuance perf)
### Risques
- Un index plein sur une colonne soft-delete nullable (`deleted_at`) indexe **tous les NULL**, c'est-à-dire la quasi-totalité des lignes (les comptes actifs). L'index est volumineux et peu sélectif pour les requêtes qui ne ciblent que les lignes supprimées.
- Distinct de l'index **unique** partiel déjà documenté (cf. `risque-prisma-unique-nullable`, qui traite de l'idempotence) : ici l'enjeu est purement la **performance / le coût d'indexation**.
### Symptômes
- `CREATE INDEX ... ON ("deleted_at")` sans clause `WHERE` sur une table où >99 % des lignes ont `deleted_at IS NULL`
- Index gros pour un bénéfice quasi nul sur les requêtes « lister les comptes supprimés »
### Bonnes pratiques / mitigations
```sql
-- ❌ Index plein — indexe la masse des NULL (lignes actives)
CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at");
-- ✅ Index partial — n'indexe que les lignes effectivement supprimées
CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at")
WHERE "deleted_at" IS NOT NULL;
```
- **Règle** : pour une colonne soft-delete nullable à majorité `NULL`, préférer un index partial `WHERE deleted_at IS NOT NULL`.
- Contexte technique : Prisma / PostgreSQL / index partial — app-alexandrie 13-04-2026
+38
View File
@@ -103,3 +103,41 @@ endOfDay.setHours(23, 59, 59, 999); // dérive selon TZ serveur
- Règle : tout `expireAt` ou `TTL` de quota journalier doit utiliser `Date.UTC()` — vérifier systématiquement en review
- Contexte technique : Redis / NestJS — app-alexandrie 20-03-2026
---
<a id="risque-compensation-incrby-non-atomique"></a>
## Compensation `incrBy(-1)` non-atomique après dépassement de quota
### Risques
- Pattern courant de quota Redis : `INCR` → vérifier `> limit` → si oui, décrémenter (`incrBy(-1)`) et lever 429. La compensation n'est PAS atomique avec le check.
- Si Redis devient indisponible (flap) entre l'incrément et la compensation, l'appel de décrément échoue silencieusement et retourne `null`. Le compteur reste incrémenté → quota fantôme jusqu'à l'expiration de la clé.
### Symptômes
- Utilisateur bloqué par le quota alors qu'il n'a pas atteint la limite réelle
- Aucune erreur visible : la valeur de retour `null` du décrément n'est pas vérifiée
- Comportement intermittent corrélé aux instabilités Redis
### Bonnes pratiques / mitigations
```typescript
// ❌ Compensation non vérifiée — échec silencieux possible
await redis.incr(key);
if (current > limit) {
await redis.incrBy(key, -1); // retourne null si Redis flap → compensation perdue
throw quotaExceeded();
}
// ✅ Vérifier le retour de la compensation et loguer
const compensated = await redis.incrBy(key, -1);
if (compensated === null) {
logger.error({ type: 'quota', event: 'compensation_failed', key });
}
```
- **Règle** : toujours vérifier le retour du décrément de compensation et loguer explicitement si `null`. Documenter ce choix dans les Dev Notes de la story (comportement intentionnel vs bug).
- **Solution robuste** : encapsuler incrément + compensation conditionnelle dans le même pipeline `MULTI/EXEC` ou un script Lua (atomicité garantie), au prix d'une complexité plus élevée.
- Contexte technique : Redis / NestJS — app-alexandrie 01-04-2026