[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CSTEQGVPAP20Z0H4NK92EG":3,"skill-same:01M6CSTEQGVPAP20Z0H4NK92EG":124},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":44,"linkOnly":45,"markdownContent":46,"markdownBody":47,"frontMatter":48,"refName":89,"commitSha":90,"skillDirectory":91,"forksCount":14,"repositoryHtmlUrl":92,"repositoryHomepageUrl":6,"ownerHtmlUrl":93,"ownerBlogUrl":6,"ownerDescription":94,"updatedAt":95,"install":96,"resources":109},"01M6CSTEQGVPAP20Z0H4NK92EG","volontariapp-file-storage-flow",null,"Playbook pour tout travail sur le stockage de fichiers (upload, scan, réservation, rattachement, nettoyage) - ms-storage, worker-storage, post-processor-storage, et rattachement d'une image à une entité métier (post, event, avatar, badge).","Volontariapp\u002Fmeta","Volontariapp","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F263673138?v=4","01M6CSQNQ3J4SKRWA512JQF5K7","VolontariApp","01M6CSQNV8T2M4PR5A22DKHY5T",0,"MIT","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-file-storage-flow\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000016","图像与多媒体","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43],"flow","file","volontariapp file storage flow","storage","avatar","post","id","worker","event","npm-packages","architecture","upload","code","image","files","scan","domain","ms","packages","processor","2026-11-10 10:12:30",false,"---\nname: volontariapp-file-storage-flow\ndescription: \"Playbook pour tout travail sur le stockage de fichiers (upload, scan, réservation, rattachement, nettoyage) - ms-storage, worker-storage, post-processor-storage, et rattachement d'une image à une entité métier (post, event, avatar, badge).\"\ntype: Agent Skill\ntitle: File Storage Flow\ntags: [storage, upload, files]\nstatus: stable\npaths:\n  - \"docs\u002Fstockage-fichiers\u002F**\"\n  - \"npm-packages\u002Fpackages\u002Fdomain-storage\u002Fsrc\u002F**\"\n  - \"ms-storage\u002Fsrc\u002F**\"\nmesh_keys:\n  - storage\n  - file_id\n  - upload\n  - confirmfileattachment\n  - quarantine\ngenerated:\n  by: claude-code\u002Fclaude-opus-5-5\n  at: \"2026-10-06T08:22:12Z\"\nsources:\n  - id: storage-docs\n    resource: docs\u002Fstockage-fichiers\n    title: Architecture du stockage de fichiers\n  - id: domain-storage\n    resource: npm-packages\u002Fpackages\u002Fdomain-storage\u002Fsrc\n    title: \"@volontariapp\u002Fdomain-storage\"\nverified:\n  - by: claude-code\u002Fagent\n    at: \"2026-10-16T23:31:30Z\"\n    digest: c2af7436c571d364\n  - by: claude-code\u002Fagent\n    at: \"2026-10-16T23:48:47Z\"\n    digest: c2af7436c571d364\n  - by: claude-code\u002Fagent\n    at: \"2026-10-17T00:15:13Z\"\n    digest: c2af7436c571d364\n  - by: claude-code\u002Fclaude-sonnet-5-5\n    at: \"2026-10-28T19:34:14Z\"\n    digest: 3e7dfcb890237eed\n  - by: claude-code\u002Fagent\n    at: \"2026-11-05T17:34:09Z\"\n    digest: da39a3ee5e6b4b0d\n---\n\n# Playbook : Stockage de Fichiers\n\nLa référence d'architecture est `docs\u002Fstockage-fichiers\u002F` (repo `Volontariapp\u002Fdocs`). Ce playbook en est le résumé opérationnel. **Lis le document du flux concerné avant de coder.**\n\n> [!IMPORTANT]\n> **Statut au 2026-11-05 : les flux principaux (avatar, post, événement) et la purge des orphelins sont implémentés de bout en bout.** Chaque libération (`releaseForOwner`, `releaseForEntity`, `releaseFile`, domain-storage 0.14.0) écrit `storage.cleanup_files` dans sa transaction ; `worker-storage` porte `CleanupFilesHandler` (objets supprimés, puis `markDeleted`) ; filet de sécurité : `requestPurge`, appelée par la commande ms-storage `dist\u002Fcommands\u002Frequest-file-purge.js`, que lance le CronJob `ms-storage-purge` (deploy) toutes les 15 minutes. Scénarios E2E : purge d'un compte supprimé (`user-deletion-scenarios`) et d'un post ou d'un avatar remplacé (`storage-release-scenarios`). Détail : `references\u002Flessons.md`. Le paragraphe suivant est l'historique de construction écrit au 2026-10-06, partiellement dépassé : lire d'abord ce résumé. `ms-storage` démarre en hybride (gRPC sur `microServices.msStorageUrl`, `0.0.0.0:5006`, plus HTTP `\u002Fhealth` sur 3006, `GrpcInternalGuard` global) et expose les handlers `GenerateUploadUrl`, `ConfirmUpload`, `ConfirmFileAttachment`, `DeleteFile` et `GetFileMetadata` (`ms-storage\u002Fsrc\u002Fmodules\u002Fstorage\u002Fcontrollers\u002F`) ; `api-gateway` expose `POST \u002Fstorage\u002Fupload-url`, `POST \u002Fstorage\u002Ffiles\u002F:id\u002Fconfirm` et `GET \u002Fstorage\u002Ffiles\u002F:id`, et `post-processor-storage` rattache et libère les fichiers (création, remplacement de couverture ou d'avatar, suppression, `user.deleted`) ; `deploy` porte MinIO, ClamAV et la base de `ms-storage` . Historique 2026-10-06 (dépassé pour `ms-storage`, `api-gateway` et `deploy`, valable pour `domain-storage`) : la migration de production de `ms-storage` `ms-storage\u002Fsrc\u002Fmigrations\u002Fdomain\u002F1791300000000-CreateFilesAndReleasedEntities.ts` crée `files` (7 index dont 5 partiels) et `released_entities` ; son test (`yarn test:migration`, opt-in via `MS_STORAGE_MIGRATION_TEST_DB_HOST`, base locale jetable qu'il vide) compare le schéma à `FileModel` ; sa configuration porte `s3.privateBucket`, `s3.publicEndpoint` (signature uniquement), `s3.publicBaseUrl` et `scanner.host` \u002F `scanner.port` \u002F `scanner.timeoutMs` (validés, variables `S3_PRIVATE_BUCKET`, `S3_PUBLIC_ENDPOINT`, `S3_PUBLIC_BASE_URL`, `SCANNER_*`), `S3Module` est importé dans `AppModule` ; `domain-storage` (tickets 1.6 à 1.10, mergés sur `main` : 0.7.0 publiée, 0.8.0 en cours de publication) a les enums `ScanStatus` \u002F `ValidationMode` \u002F `RejectionReason`, `FileStatus.RESERVED`, `VALIDATION_POLICY_BY_ENTITY` et `resolveValidationMode` (SVG retiré) et les helpers purs `buildQuarantineObjectKey` \u002F `buildPublicObjectKey` \u002F `buildPublicFileUrl` (`src\u002Fhelpers\u002F`) et les modèles TypeORM `FileModel` (table `files`) et `ReleasedEntityModel` (table `released_entities`, colonnes en snake_case explicites, `src\u002Fmodels\u002F`, sous-chemin `.\u002Fmodels`), et un `PostgresFileRepository` partiel (sous-chemin `.\u002Frepositories`, jamais réexporté par la racine, `src\u002Frepositories\u002F`), refactoré en 0.9.0 sur le pattern de `domain-user` : `FileEntity` (classe pure `src\u002Fentities\u002F`, exportée par la racine ; `FileEntity.create` porte les invariants : MIME autorisé, mode SYNC\u002FASYNC depuis la taille déclarée, clé de quarantaine, `uploadExpiresAt` = création (toujours l'heure courante, jamais fournie par l'appelant) + `presignedUrlTtlSeconds` (au plus `MAX_PRESIGNED_URL_TTL_SECONDS`, 7 jours, sinon `BadRequestError`) + 5 min) et `ReleasedEntityEntity`, `registerStorageMappings()` (`src\u002Fmodels\u002Fmapper.ts`, appelé à l'import du sous-chemin `.\u002Fmodels`, enregistre aussi les paires outbox ; contrairement à `domain-post` et `domain-event`, qui l'appellent à l'import de leur racine, la racine de domain-storage ne doit pas charger typeorm ; `domain-user` laisse l'appel au consommateur), `IFileRepository` (renvoie des entités), `PostgresFileRepository extends BaseRepository\u003CFileModel, FileEntity>` (`@Injectable`, constructeur `@InjectRepository(FileModel) Repository\u003CFileModel>`, plus de `DataSource`), outbox écrite via `JobsOutboxRepository` \u002F `EventQueueRepository` de `@volontariapp\u002Foutbox` sur `manager.getRepository(...)` de la transaction ; `typeorm` est résolu en deux copies (Yarn instancie un peer par jeu de peers) : le `tsconfig.json` de domain-storage mappe `typeorm` vers la copie hissée pour que `Repository` \u002F `EntityManager` restent assignables à ceux de `database` \u002F `outbox` (sans ça TS2345 \"Property 'findOptions' is protected\") ; le TS2589 invoqué avant n'a pas pu être reproduit. Méthodes : `createPending`, `confirmUpload`, `switchToAsync`, `resetToAwaitingUpload`, `completeScan`, `rejectScan`. Chaque transition est un `UPDATE ... RETURNING` conditionnel en READ COMMITTED, renvoie `null` \u002F `false` si aucune ligne ne correspond, et écrit `jobs_outbox` (`storage.scan_file`) ou `event_queue` (`storage.file_scanned` \u002F `storage.file_rejected`, seulement si la ligne est `ATTACHED`) dans la même transaction. Les tests d'intégration (`yarn test:integration`, base `postgres-storage` sur `localhost:5437`, qui fait un `dropDatabase()` : refusé hors hôte local) chargent la migration de test `src\u002Ftest\u002Fmigrations\u002Fdomain\u002F` puis les migrations communes copiées de domain-post (`src\u002Ftest\u002Fmigrations\u002Fcommon\u002F`). **Limite connue (revérifiée le 2026-10-28 sur `origin\u002Fmain`, domain-storage 0.12.9)** : `@volontariapp\u002Fshared` porte désormais `Streams.STORAGE_FILE_SCANNED`, `STORAGE_FILE_REJECTED` et `STORAGE_ATTACHMENT_REJECTED`, mais `FILE_SCAN_RESULT_TARGET_SERVICES` (`src\u002Frepositories\u002Ffile-outbox.builders.ts`) est toujours `[]` : les événements storage sont donc écrits avec `targetServices` vide et le pusher les ignore tant que la constante n'est pas renseignée. ATTENTION : avec `targetServices` vide, le pusher d'outbox saute la ligne sans erreur et le consommateur la marque `COMPLETED` : l'événement est perdu en silence et le média du post reste `PENDING`, donc ne pas brancher `completeScan` \u002F `rejectScan` dans `ms-storage` ou `worker-storage` avant que `StorageStream` existe. `reserve` (ticket 1.10, domain-storage 0.8.0) existe : `reserve({ fileIds, entityType, entityId, ownerId })` dedoublonne, refuse au-dela de `maxPerEntity` (`TooManyFilesException`, ids distincts de l'appel, pas le cumul en base : un avatar remplace peut coexister avec l'ancien `ATTACHED`), verrouille en `SELECT ... FOR UPDATE ORDER BY id`, classe chaque fichier avec la regle pure `classifyFileForAttachment` (`src\u002Fpolicies\u002Fattachment-validation.rule.ts`, a reutiliser telle quelle dans `attachFromConfirmationEvent` : verdicts `RESERVE` \u002F `ALREADY_HELD` \u002F `NOT_FOUND` \u002F `REFUSED` + `AttachmentRefusalReason`), puis un seul `UPDATE ... WHERE status = 'PENDING'` pour tout reserver ; tout ou rien, `FileNotFoundException` (404, prioritaire) ou `FileAttachmentRefusedException` (422, `code` `FILE_ATTACHMENT_REFUSED`, `details.reason` ; son `grpcCode` est FAILED_PRECONDITION : elle étend `BaseApiError` directement, car `UnprocessableEntityError` fixe INVALID_ARGUMENT et le filtre global renvoie `grpcCode` tel quel, il n'existe aucun mapping par `code`) ; aucun evenement ecrit. `attachFromConfirmationEvent`, `releaseForEntity`, `releaseFile`, `releaseForOwner` (ticket 1.11, domain-storage 0.10.0, PR en cours de publication) existent : `attachFromConfirmationEvent({ fileIds, entityType, entityId, ownerId })` tourne dans une transaction sous `pg_advisory_xact_lock(hashtext(entity_type:entity_id))` (verrou par entité, doc 11 P1) ; si l'entité a une pierre tombale, les fichiers nommés `PENDING` \u002F `RESERVED` du propriétaire passent `ORPHANED` sans événement ; sinon `SELECT ... FOR UPDATE ORDER BY id`, classement par `classifyFileForConfirmation` (`src\u002Fpolicies\u002Fattachment-validation.rule.ts`, qui délègue à `classifyFileForAttachment` pour tout fichier que l'entité ne tient pas déjà ; un fichier déjà tenu par la même entité est jugé sur son statut seul : `RESERVED` à rattacher, `ATTACHED` rejeu, `ORPHANED` \u002F `DELETED` libération arrivée avant), puis un seul `UPDATE ... RETURNING` vers `ATTACHED` ; un fichier rattaché dont le scan est terminal écrit `storage.file_scanned` \u002F `storage.file_rejected`, un fichier refusé écrit `storage.attachment_rejected` (raisons `NOT_FOUND`, `WRONG_ENTITY_TYPE`, `NOT_CONFIRMED`, `CONTENT_REJECTED`, `ALREADY_ATTACHED`, via `toAttachmentRejectionReason`), un rejeu n'écrit rien ; résultat `FileAttachmentResult[]` (`FileAttachmentOutcome`) ; une pierre tombale ne produit pas de `attachment_rejected` (messaging 2.19.0 n'a pas `ENTITY_RELEASED`) ; un rejeu d'un refus réécrit un `attachment_rejected` (pas d'état pour dédupliquer). `releaseForEntity({ entityType, entityId })` écrit la pierre tombale (`ReleasedEntityEntity`, `ON CONFLICT DO NOTHING`) et passe `RESERVED` \u002F `ATTACHED` de l'entité en `ORPHANED`, même verrou, aucun événement. `releaseFile({ fileId, entityType, entityId, ownerId?, newFileId? })` : un `UPDATE` conditionnel depuis `PENDING` (propriétaire obligatoire, `entity_id` renseigné), `RESERVED` ou `ATTACHED` (condition sur l'entité, propriétaire facultatif pour un événement sans propriétaire comme l'icône de badge, doc 11 P7), ignoré si `newFileId` est le même fichier. `releaseForOwner({ ownerId })` passe en `ORPHANED` tout `PENDING` \u002F `RESERVED` \u002F `ATTACHED` du propriétaire sauf `BADGE_ICON` (`OWNER_RELEASE_EXCLUDED_ENTITY_TYPES`), sans pierre tombale. Ces événements storage (`file_scanned`, `file_rejected` et désormais `attachment_rejected`) sont tous écrits avec `targetServices` vide tant que `StorageStream` n'existe pas : ne pas brancher ces méthodes dans `post-processor-storage` avant le ticket 1.14. `user.badge_created` ne porte pas de `userId` : le ticket 4.x doit décider du propriétaire. Le client mobile appelle le stockage par `nativapp\u002Fsrc\u002Fapi\u002Fstorage\u002F` et `use-post-images.ts` ; le symbole `useUploadFile` n'a pas été trouvé dans `nativapp\u002Fsrc`. Toujours partir de `01-etat-des-lieux.md` et de `10-plan-implementation.md` (vagues, chacune terminée par la règle du STOP).\n\n| Besoin | Document |\n| :--- | :--- |\n| État réel du code | `docs\u002Fstockage-fichiers\u002F01-etat-des-lieux.md` |\n| Responsabilités, packages, buckets, modes, pipeline, ids d'entité, visibilité | `docs\u002Fstockage-fichiers\u002F02-architecture-cible.md` |\n| `FileStatus`, `ScanStatus`, réservation, règle d'émission, commutativité | `docs\u002Fstockage-fichiers\u002F03-cycle-de-vie-fichier.md` |\n| Images d'un post (ASYNC) | `docs\u002Fstockage-fichiers\u002F04-flux-post.md` |\n| Photo d'un événement (ASYNC) | `docs\u002Fstockage-fichiers\u002F05-flux-event.md` |\n| Avatar et icône de badge (SYNC) | `docs\u002Fstockage-fichiers\u002F06-flux-avatar.md` |\n| Libération, suppression de compte, purge | `docs\u002Fstockage-fichiers\u002F07-nettoyage-et-orphelins.md` |\n| Proto, messaging, shared, domain-*, SQL, configuration | `docs\u002Fstockage-fichiers\u002F08-contrats-et-evolutions.md` |\n| Menaces, codes d'erreur | `docs\u002Fstockage-fichiers\u002F09-securite.md` |\n| Ordre des tickets | `docs\u002Fstockage-fichiers\u002F10-plan-implementation.md` |\n| Comportement en panne | `docs\u002Fstockage-fichiers\u002F11-scenarios-de-panne.md` |\n\n---\n\n## Invariants (non négociables)\n\n1. **Aucun octet de fichier ne transite par `api-gateway` ni par un `ms-*` métier.** Le client uploade directement sur S3 via un POST signé, signé avec `s3.publicEndpoint`.\n2. **Tout upload arrive en quarantaine** (`volontariapp-private`, clé `quarantine\u002F{fileId}`). Seule une version traitée (magic bytes, clamd, ré-encodage) est écrite dans le bucket public, qui n'autorise que `s3:GetObject` anonyme.\n3. **Traitement SYNC par défaut, ASYNC si le fichier dépasse le seuil SYNC de son `EntityType` ou si le pipeline synchrone échoue techniquement.** Le mode vient de `domain-storage` (`resolveValidationMode`, taille déclarée), jamais du client. Avatar et badge : toujours SYNC, 503 sur erreur technique. SVG refusé.\n4. **Les services métier stockent des `file_id`, jamais d'URL.** L'URL publique se calcule avec `buildPublicFileUrl` (`domain-storage`).\n5. **`owner_id` vient de l'`INTERNAL_TOKEN`** (`CurrentUser`), jamais du payload. Le client storage partagé propage `x-internal-token` dans les `Metadata`.\n6. **Rattachement synchrone par défaut : `ConfirmFileAttachment` (deadline 2 s), avant l'écriture de l'entité et avant tout `withFallback`.** Sur `UNAVAILABLE` \u002F `DEADLINE_EXCEEDED` : post et event passent en rattachement asynchrone (entité écrite avec statut média `PENDING`, validation par `pp-storage`), avatar et badge répondent 503. Les erreurs `NOT_FOUND` \u002F `FAILED_PRECONDITION` ne déclenchent jamais de fallback. Une politique SYNC exige `scan_status = CLEAN`, une politique ASYNC accepte `SCANNING`. `VerifyFilesExist` est déprécié.\n7. **L'id d'entité est calculé avant l'écriture** : `UUID v5(ownerId, idempotencyKey)` à la création. Il est transporté dans les payloads de fallback. La création est idempotente : `INSERT ... ON CONFLICT (id) DO NOTHING`, sans réémettre l'événement de création si 0 ligne (jamais `manager.save`).\n8. **L'événement de création ou de remplacement est écrit dans `event_queue` dans la même transaction que l'entité, avec les `fileIds` et le propriétaire.** C'est lui qui confirme le rattachement (`RESERVED` ou `PENDING` validé vers `ATTACHED`). La règle de validation est unique, dans `PostgresFileRepository`, partagée par `reserve` et `attachFromConfirmationEvent`.\n9. **Rattachement depuis `RESERVED` (même entité) ou `PENDING` validé, libération par entité avec pierre tombale (`released_entities`), libération d'un fichier nommé depuis `PENDING` \u002F `RESERVED` \u002F `ATTACHED`, `ORPHANED` absorbant.** Jamais de condition de date sur le rattachement. Aucun `*_replaced` si le fichier ne change pas (`newFileId = oldFileId`).\n10. **`storage.file_scanned` \u002F `storage.file_rejected` ne sont émis que pour un fichier `ATTACHED` au `scan_status` terminal**, par la transition qui arrive en second (fin de traitement ou rattachement). Conditions : READ COMMITTED, un seul `UPDATE ... RETURNING` par transition, écriture `event_queue` dans la même transaction.\n11. **Fin de traitement par `UPDATE` conditionnel** (`scan_status = 'SCANNING' AND status NOT IN ('ORPHANED','DELETED')`). Si 0 ligne, supprimer l'objet public écrit, sauf exécution en double d'un fichier toujours `CLEAN` et non libéré. En SYNC, une erreur technique remet le fichier en `AWAITING_UPLOAD` (échéance repoussée de 15 min) avant le 503. La purge relance un `SCANNING` bloqué une seule fois, via `rescan_scheduled_at`.\n12. **Un post-processor métier qui ne trouve plus l'entité acquitte avec un log `warn`** : la règle d'émission garantit qu'elle existait, elle a donc été supprimée depuis.\n13. **`FileStatus` et `ScanStatus` sont deux axes distincts. `media_status` \u002F `cover_status` sont distincts de `saga_status`.**\n14. **`ws-service` n'est jamais un maillon métier.**\n15. **Persistance `files` dans `domain-storage`** (`FileModel`, `PostgresFileRepository`, `.\u002Frepositories`), partagée par `ms-storage`, `worker-storage` et `post-processor-storage`. Le pipeline S3 \u002F clamd \u002F ré-encodage vit dans le package d'infrastructure storage.\n16. **La table d'événements s'appelle `event_queue`**, pas `event_outbox`.\n\n---\n\n## Recette : rattacher une image à un nouveau type d'entité\n\nExemple : ajouter une image à une \"organisation\".\n\n1. **`proto-registry`** (skill `volontariapp-proto-contract-evolution`) :\n   - nouvelle valeur `EntityType` (additive) ;\n   - numéros de champs fixés au-dessus du maximum actuel, `reserved` sur tout numéro supprimé sans réservation ;\n   - `optional string \u003Cx>_file_id` dans l'entité, et dans la commande de création avec `string idempotency_key` ;\n   - pour une mise à jour par `Entity` + `update_mask`, le champ va **uniquement** dans l'entité ;\n   - statut média (enum préfixé, `_UNSPECIFIED = 0`) si le mode est ASYNC ;\n   - **STOP**.\n2. **`npm-packages`** (skill `volontariapp-shared-npm-package-change`) :\n   - `domain-storage` : entrée dans `VALIDATION_POLICY_BY_ENTITY` (mode, taille, MIME, format, maximum par entité) et préfixe de clé publique ;\n   - `messaging` : événements `\u003Cdomaine>.\u003Cx>_replaced` `{ \u003Centity>Id, newFileId?, oldFileId? }` et, si absents, `created` \u002F `deleted` ; payloads de fallback avec l'id calculé ;\n   - `shared` : streams correspondants ;\n   - `domain-\u003Cx>` : id fourni à la création, colonne `\u003Cx>_file_id` (+ statut si ASYNC), écriture de l'entité et de l'événement dans la même transaction, lecture de l'ancien `file_id` sous `SELECT ... FOR UPDATE` lors d'un remplacement ;\n   - **STOP**.\n3. **Service métier** : calcul de l'id, appel `ConfirmFileAttachment` via le client storage partagé avant `withFallback`, statut initial selon le `scan_status` renvoyé. Décider si l'entité supporte le rattachement asynchrone (elle doit alors avoir un statut média) ou répond 503 quand `ms-storage` est indisponible.\n4. **`post-processor-storage`** (skill `volontariapp-implement-async-event-flow`) : consommer création, remplacement, suppression et échec de saga de l'entité. Les échecs de saga (`*.creation_failed`) sont émis par `ws-service` sur les streams partagés `ws:*-created-feedback` : filtrer par type.\n5. **Si l'entité a un statut média** : le post-processor du domaine consomme `storage.file_scanned` \u002F `storage.file_rejected` \u002F `storage.attachment_rejected`, filtre sur `entityType`, ignore les `fileId` qui ne sont plus courants, met à jour le statut de façon idempotente, acquitte avec `warn` si l'entité a été supprimée.\n6. **`api-gateway`** : relayer `\u003Cx>FileId` et `idempotencyKey`, exposer l'URL calculée par `buildPublicFileUrl`.\n7. **`nativapp`** : réutiliser `useUploadFile(entityType)`, générer une `idempotencyKey` par formulaire, gérer `SCANNING` \u002F `REJECTED` si ASYNC.\n8. **Suppression de compte** : décider si les fichiers de ce type sont libérés par `user.deleted` (par défaut oui, sauf ressources de la plateforme comme `BADGE_ICON`).\n\n---\n\n## Checklist de revue\n\n- [ ] Aucun `any`, aucun `as unknown as`, aucun cast sur les erreurs gRPC (type guard dans le client storage partagé).\n- [ ] Aucune duplication du pipeline entre `ms-storage` (SYNC) et `worker-storage` (ASYNC), ni de la persistance `files`.\n- [ ] Codes d'erreur conformes à `09-securite.md` (aucun 500 pour un fichier invalide, `NOT_FOUND` pour le fichier d'un autre).\n- [ ] Toute transition est un `UPDATE` conditionnel, idempotent et loggé via `@volontariapp\u002Flogger`.\n- [ ] Les deux ordres \"scan puis rattachement\" et \"rattachement puis scan\" sont couverts par des tests, pour les chemins de rattachement synchrone et asynchrone.\n- [ ] Les scénarios de `11-scenarios-de-panne.md` concernés par le changement sont testés (clamd arrêté, `ms-storage` arrêté).\n- [ ] Mocks dans `*.mock.ts`, factories dans `*.factory.ts`, `jest.spyOn` + `restoreAllMocks()`.\n- [ ] Noms de fichiers et de répertoires en kebab-case.\n\n## Diagnostic\n\n- Fichier bloqué en `SCANNING` : vérifier `jobs_outbox` \u002F `job_audit` de `ms_storage` (skill `volontariapp-trace-async-flow`). La purge relance un job après 2 h et rejette après 12 h.\n- Fichier resté `RESERVED` : l'événement de confirmation n'a pas été émis (entité jamais écrite, fallback en attente) ou `pp-storage` ne le consomme pas. Libéré automatiquement après 7 jours.\n- Entité restée `PENDING` alors que le fichier est `CLEAN` : vérifier que le fichier est bien `ATTACHED`, puis que `storage.file_scanned` a été écrit dans `event_queue`.\n- Qui produit \u002F consomme un événement : `analyze_impact({ target: \"storage.file_scanned\" })` (mesh-mcp).\n- Qui appelle `StorageService` : `analyze_grpc({ target: \"StorageService\" })` (mesh-mcp).\n","# Playbook : Stockage de Fichiers\n\nLa référence d'architecture est `docs\u002Fstockage-fichiers\u002F` (repo `Volontariapp\u002Fdocs`). Ce playbook en est le résumé opérationnel. **Lis le document du flux concerné avant de coder.**\n\n> [!IMPORTANT]\n> **Statut au 2026-11-05 : les flux principaux (avatar, post, événement) et la purge des orphelins sont implémentés de bout en bout.** Chaque libération (`releaseForOwner`, `releaseForEntity`, `releaseFile`, domain-storage 0.14.0) écrit `storage.cleanup_files` dans sa transaction ; `worker-storage` porte `CleanupFilesHandler` (objets supprimés, puis `markDeleted`) ; filet de sécurité : `requestPurge`, appelée par la commande ms-storage `dist\u002Fcommands\u002Frequest-file-purge.js`, que lance le CronJob `ms-storage-purge` (deploy) toutes les 15 minutes. Scénarios E2E : purge d'un compte supprimé (`user-deletion-scenarios`) et d'un post ou d'un avatar remplacé (`storage-release-scenarios`). Détail : `references\u002Flessons.md`. Le paragraphe suivant est l'historique de construction écrit au 2026-10-06, partiellement dépassé : lire d'abord ce résumé. `ms-storage` démarre en hybride (gRPC sur `microServices.msStorageUrl`, `0.0.0.0:5006`, plus HTTP `\u002Fhealth` sur 3006, `GrpcInternalGuard` global) et expose les handlers `GenerateUploadUrl`, `ConfirmUpload`, `ConfirmFileAttachment`, `DeleteFile` et `GetFileMetadata` (`ms-storage\u002Fsrc\u002Fmodules\u002Fstorage\u002Fcontrollers\u002F`) ; `api-gateway` expose `POST \u002Fstorage\u002Fupload-url`, `POST \u002Fstorage\u002Ffiles\u002F:id\u002Fconfirm` et `GET \u002Fstorage\u002Ffiles\u002F:id`, et `post-processor-storage` rattache et libère les fichiers (création, remplacement de couverture ou d'avatar, suppression, `user.deleted`) ; `deploy` porte MinIO, ClamAV et la base de `ms-storage` . Historique 2026-10-06 (dépassé pour `ms-storage`, `api-gateway` et `deploy`, valable pour `domain-storage`) : la migration de production de `ms-storage` `ms-storage\u002Fsrc\u002Fmigrations\u002Fdomain\u002F1791300000000-CreateFilesAndReleasedEntities.ts` crée `files` (7 index dont 5 partiels) et `released_entities` ; son test (`yarn test:migration`, opt-in via `MS_STORAGE_MIGRATION_TEST_DB_HOST`, base locale jetable qu'il vide) compare le schéma à `FileModel` ; sa configuration porte `s3.privateBucket`, `s3.publicEndpoint` (signature uniquement), `s3.publicBaseUrl` et `scanner.host` \u002F `scanner.port` \u002F `scanner.timeoutMs` (validés, variables `S3_PRIVATE_BUCKET`, `S3_PUBLIC_ENDPOINT`, `S3_PUBLIC_BASE_URL`, `SCANNER_*`), `S3Module` est importé dans `AppModule` ; `domain-storage` (tickets 1.6 à 1.10, mergés sur `main` : 0.7.0 publiée, 0.8.0 en cours de publication) a les enums `ScanStatus` \u002F `ValidationMode` \u002F `RejectionReason`, `FileStatus.RESERVED`, `VALIDATION_POLICY_BY_ENTITY` et `resolveValidationMode` (SVG retiré) et les helpers purs `buildQuarantineObjectKey` \u002F `buildPublicObjectKey` \u002F `buildPublicFileUrl` (`src\u002Fhelpers\u002F`) et les modèles TypeORM `FileModel` (table `files`) et `ReleasedEntityModel` (table `released_entities`, colonnes en snake_case explicites, `src\u002Fmodels\u002F`, sous-chemin `.\u002Fmodels`), et un `PostgresFileRepository` partiel (sous-chemin `.\u002Frepositories`, jamais réexporté par la racine, `src\u002Frepositories\u002F`), refactoré en 0.9.0 sur le pattern de `domain-user` : `FileEntity` (classe pure `src\u002Fentities\u002F`, exportée par la racine ; `FileEntity.create` porte les invariants : MIME autorisé, mode SYNC\u002FASYNC depuis la taille déclarée, clé de quarantaine, `uploadExpiresAt` = création (toujours l'heure courante, jamais fournie par l'appelant) + `presignedUrlTtlSeconds` (au plus `MAX_PRESIGNED_URL_TTL_SECONDS`, 7 jours, sinon `BadRequestError`) + 5 min) et `ReleasedEntityEntity`, `registerStorageMappings()` (`src\u002Fmodels\u002Fmapper.ts`, appelé à l'import du sous-chemin `.\u002Fmodels`, enregistre aussi les paires outbox ; contrairement à `domain-post` et `domain-event`, qui l'appellent à l'import de leur racine, la racine de domain-storage ne doit pas charger typeorm ; `domain-user` laisse l'appel au consommateur), `IFileRepository` (renvoie des entités), `PostgresFileRepository extends BaseRepository\u003CFileModel, FileEntity>` (`@Injectable`, constructeur `@InjectRepository(FileModel) Repository\u003CFileModel>`, plus de `DataSource`), outbox écrite via `JobsOutboxRepository` \u002F `EventQueueRepository` de `@volontariapp\u002Foutbox` sur `manager.getRepository(...)` de la transaction ; `typeorm` est résolu en deux copies (Yarn instancie un peer par jeu de peers) : le `tsconfig.json` de domain-storage mappe `typeorm` vers la copie hissée pour que `Repository` \u002F `EntityManager` restent assignables à ceux de `database` \u002F `outbox` (sans ça TS2345 \"Property 'findOptions' is protected\") ; le TS2589 invoqué avant n'a pas pu être reproduit. Méthodes : `createPending`, `confirmUpload`, `switchToAsync`, `resetToAwaitingUpload`, `completeScan`, `rejectScan`. Chaque transition est un `UPDATE ... RETURNING` conditionnel en READ COMMITTED, renvoie `null` \u002F `false` si aucune ligne ne correspond, et écrit `jobs_outbox` (`storage.scan_file`) ou `event_queue` (`storage.file_scanned` \u002F `storage.file_rejected`, seulement si la ligne est `ATTACHED`) dans la même transaction. Les tests d'intégration (`yarn test:integration`, base `postgres-storage` sur `localhost:5437`, qui fait un `dropDatabase()` : refusé hors hôte local) chargent la migration de test `src\u002Ftest\u002Fmigrations\u002Fdomain\u002F` puis les migrations communes copiées de domain-post (`src\u002Ftest\u002Fmigrations\u002Fcommon\u002F`). **Limite connue (revérifiée le 2026-10-28 sur `origin\u002Fmain`, domain-storage 0.12.9)** : `@volontariapp\u002Fshared` porte désormais `Streams.STORAGE_FILE_SCANNED`, `STORAGE_FILE_REJECTED` et `STORAGE_ATTACHMENT_REJECTED`, mais `FILE_SCAN_RESULT_TARGET_SERVICES` (`src\u002Frepositories\u002Ffile-outbox.builders.ts`) est toujours `[]` : les événements storage sont donc écrits avec `targetServices` vide et le pusher les ignore tant que la constante n'est pas renseignée. ATTENTION : avec `targetServices` vide, le pusher d'outbox saute la ligne sans erreur et le consommateur la marque `COMPLETED` : l'événement est perdu en silence et le média du post reste `PENDING`, donc ne pas brancher `completeScan` \u002F `rejectScan` dans `ms-storage` ou `worker-storage` avant que `StorageStream` existe. `reserve` (ticket 1.10, domain-storage 0.8.0) existe : `reserve({ fileIds, entityType, entityId, ownerId })` dedoublonne, refuse au-dela de `maxPerEntity` (`TooManyFilesException`, ids distincts de l'appel, pas le cumul en base : un avatar remplace peut coexister avec l'ancien `ATTACHED`), verrouille en `SELECT ... FOR UPDATE ORDER BY id`, classe chaque fichier avec la regle pure `classifyFileForAttachment` (`src\u002Fpolicies\u002Fattachment-validation.rule.ts`, a reutiliser telle quelle dans `attachFromConfirmationEvent` : verdicts `RESERVE` \u002F `ALREADY_HELD` \u002F `NOT_FOUND` \u002F `REFUSED` + `AttachmentRefusalReason`), puis un seul `UPDATE ... WHERE status = 'PENDING'` pour tout reserver ; tout ou rien, `FileNotFoundException` (404, prioritaire) ou `FileAttachmentRefusedException` (422, `code` `FILE_ATTACHMENT_REFUSED`, `details.reason` ; son `grpcCode` est FAILED_PRECONDITION : elle étend `BaseApiError` directement, car `UnprocessableEntityError` fixe INVALID_ARGUMENT et le filtre global renvoie `grpcCode` tel quel, il n'existe aucun mapping par `code`) ; aucun evenement ecrit. `attachFromConfirmationEvent`, `releaseForEntity`, `releaseFile`, `releaseForOwner` (ticket 1.11, domain-storage 0.10.0, PR en cours de publication) existent : `attachFromConfirmationEvent({ fileIds, entityType, entityId, ownerId })` tourne dans une transaction sous `pg_advisory_xact_lock(hashtext(entity_type:entity_id))` (verrou par entité, doc 11 P1) ; si l'entité a une pierre tombale, les fichiers nommés `PENDING` \u002F `RESERVED` du propriétaire passent `ORPHANED` sans événement ; sinon `SELECT ... FOR UPDATE ORDER BY id`, classement par `classifyFileForConfirmation` (`src\u002Fpolicies\u002Fattachment-validation.rule.ts`, qui délègue à `classifyFileForAttachment` pour tout fichier que l'entité ne tient pas déjà ; un fichier déjà tenu par la même entité est jugé sur son statut seul : `RESERVED` à rattacher, `ATTACHED` rejeu, `ORPHANED` \u002F `DELETED` libération arrivée avant), puis un seul `UPDATE ... RETURNING` vers `ATTACHED` ; un fichier rattaché dont le scan est terminal écrit `storage.file_scanned` \u002F `storage.file_rejected`, un fichier refusé écrit `storage.attachment_rejected` (raisons `NOT_FOUND`, `WRONG_ENTITY_TYPE`, `NOT_CONFIRMED`, `CONTENT_REJECTED`, `ALREADY_ATTACHED`, via `toAttachmentRejectionReason`), un rejeu n'écrit rien ; résultat `FileAttachmentResult[]` (`FileAttachmentOutcome`) ; une pierre tombale ne produit pas de `attachment_rejected` (messaging 2.19.0 n'a pas `ENTITY_RELEASED`) ; un rejeu d'un refus réécrit un `attachment_rejected` (pas d'état pour dédupliquer). `releaseForEntity({ entityType, entityId })` écrit la pierre tombale (`ReleasedEntityEntity`, `ON CONFLICT DO NOTHING`) et passe `RESERVED` \u002F `ATTACHED` de l'entité en `ORPHANED`, même verrou, aucun événement. `releaseFile({ fileId, entityType, entityId, ownerId?, newFileId? })` : un `UPDATE` conditionnel depuis `PENDING` (propriétaire obligatoire, `entity_id` renseigné), `RESERVED` ou `ATTACHED` (condition sur l'entité, propriétaire facultatif pour un événement sans propriétaire comme l'icône de badge, doc 11 P7), ignoré si `newFileId` est le même fichier. `releaseForOwner({ ownerId })` passe en `ORPHANED` tout `PENDING` \u002F `RESERVED` \u002F `ATTACHED` du propriétaire sauf `BADGE_ICON` (`OWNER_RELEASE_EXCLUDED_ENTITY_TYPES`), sans pierre tombale. Ces événements storage (`file_scanned`, `file_rejected` et désormais `attachment_rejected`) sont tous écrits avec `targetServices` vide tant que `StorageStream` n'existe pas : ne pas brancher ces méthodes dans `post-processor-storage` avant le ticket 1.14. `user.badge_created` ne porte pas de `userId` : le ticket 4.x doit décider du propriétaire. Le client mobile appelle le stockage par `nativapp\u002Fsrc\u002Fapi\u002Fstorage\u002F` et `use-post-images.ts` ; le symbole `useUploadFile` n'a pas été trouvé dans `nativapp\u002Fsrc`. Toujours partir de `01-etat-des-lieux.md` et de `10-plan-implementation.md` (vagues, chacune terminée par la règle du STOP).\n\n| Besoin | Document |\n| :--- | :--- |\n| État réel du code | `docs\u002Fstockage-fichiers\u002F01-etat-des-lieux.md` |\n| Responsabilités, packages, buckets, modes, pipeline, ids d'entité, visibilité | `docs\u002Fstockage-fichiers\u002F02-architecture-cible.md` |\n| `FileStatus`, `ScanStatus`, réservation, règle d'émission, commutativité | `docs\u002Fstockage-fichiers\u002F03-cycle-de-vie-fichier.md` |\n| Images d'un post (ASYNC) | `docs\u002Fstockage-fichiers\u002F04-flux-post.md` |\n| Photo d'un événement (ASYNC) | `docs\u002Fstockage-fichiers\u002F05-flux-event.md` |\n| Avatar et icône de badge (SYNC) | `docs\u002Fstockage-fichiers\u002F06-flux-avatar.md` |\n| Libération, suppression de compte, purge | `docs\u002Fstockage-fichiers\u002F07-nettoyage-et-orphelins.md` |\n| Proto, messaging, shared, domain-*, SQL, configuration | `docs\u002Fstockage-fichiers\u002F08-contrats-et-evolutions.md` |\n| Menaces, codes d'erreur | `docs\u002Fstockage-fichiers\u002F09-securite.md` |\n| Ordre des tickets | `docs\u002Fstockage-fichiers\u002F10-plan-implementation.md` |\n| Comportement en panne | `docs\u002Fstockage-fichiers\u002F11-scenarios-de-panne.md` |\n\n---\n\n## Invariants (non négociables)\n\n1. **Aucun octet de fichier ne transite par `api-gateway` ni par un `ms-*` métier.** Le client uploade directement sur S3 via un POST signé, signé avec `s3.publicEndpoint`.\n2. **Tout upload arrive en quarantaine** (`volontariapp-private`, clé `quarantine\u002F{fileId}`). Seule une version traitée (magic bytes, clamd, ré-encodage) est écrite dans le bucket public, qui n'autorise que `s3:GetObject` anonyme.\n3. **Traitement SYNC par défaut, ASYNC si le fichier dépasse le seuil SYNC de son `EntityType` ou si le pipeline synchrone échoue techniquement.** Le mode vient de `domain-storage` (`resolveValidationMode`, taille déclarée), jamais du client. Avatar et badge : toujours SYNC, 503 sur erreur technique. SVG refusé.\n4. **Les services métier stockent des `file_id`, jamais d'URL.** L'URL publique se calcule avec `buildPublicFileUrl` (`domain-storage`).\n5. **`owner_id` vient de l'`INTERNAL_TOKEN`** (`CurrentUser`), jamais du payload. Le client storage partagé propage `x-internal-token` dans les `Metadata`.\n6. **Rattachement synchrone par défaut : `ConfirmFileAttachment` (deadline 2 s), avant l'écriture de l'entité et avant tout `withFallback`.** Sur `UNAVAILABLE` \u002F `DEADLINE_EXCEEDED` : post et event passent en rattachement asynchrone (entité écrite avec statut média `PENDING`, validation par `pp-storage`), avatar et badge répondent 503. Les erreurs `NOT_FOUND` \u002F `FAILED_PRECONDITION` ne déclenchent jamais de fallback. Une politique SYNC exige `scan_status = CLEAN`, une politique ASYNC accepte `SCANNING`. `VerifyFilesExist` est déprécié.\n7. **L'id d'entité est calculé avant l'écriture** : `UUID v5(ownerId, idempotencyKey)` à la création. Il est transporté dans les payloads de fallback. La création est idempotente : `INSERT ... ON CONFLICT (id) DO NOTHING`, sans réémettre l'événement de création si 0 ligne (jamais `manager.save`).\n8. **L'événement de création ou de remplacement est écrit dans `event_queue` dans la même transaction que l'entité, avec les `fileIds` et le propriétaire.** C'est lui qui confirme le rattachement (`RESERVED` ou `PENDING` validé vers `ATTACHED`). La règle de validation est unique, dans `PostgresFileRepository`, partagée par `reserve` et `attachFromConfirmationEvent`.\n9. **Rattachement depuis `RESERVED` (même entité) ou `PENDING` validé, libération par entité avec pierre tombale (`released_entities`), libération d'un fichier nommé depuis `PENDING` \u002F `RESERVED` \u002F `ATTACHED`, `ORPHANED` absorbant.** Jamais de condition de date sur le rattachement. Aucun `*_replaced` si le fichier ne change pas (`newFileId = oldFileId`).\n10. **`storage.file_scanned` \u002F `storage.file_rejected` ne sont émis que pour un fichier `ATTACHED` au `scan_status` terminal**, par la transition qui arrive en second (fin de traitement ou rattachement). Conditions : READ COMMITTED, un seul `UPDATE ... RETURNING` par transition, écriture `event_queue` dans la même transaction.\n11. **Fin de traitement par `UPDATE` conditionnel** (`scan_status = 'SCANNING' AND status NOT IN ('ORPHANED','DELETED')`). Si 0 ligne, supprimer l'objet public écrit, sauf exécution en double d'un fichier toujours `CLEAN` et non libéré. En SYNC, une erreur technique remet le fichier en `AWAITING_UPLOAD` (échéance repoussée de 15 min) avant le 503. La purge relance un `SCANNING` bloqué une seule fois, via `rescan_scheduled_at`.\n12. **Un post-processor métier qui ne trouve plus l'entité acquitte avec un log `warn`** : la règle d'émission garantit qu'elle existait, elle a donc été supprimée depuis.\n13. **`FileStatus` et `ScanStatus` sont deux axes distincts. `media_status` \u002F `cover_status` sont distincts de `saga_status`.**\n14. **`ws-service` n'est jamais un maillon métier.**\n15. **Persistance `files` dans `domain-storage`** (`FileModel`, `PostgresFileRepository`, `.\u002Frepositories`), partagée par `ms-storage`, `worker-storage` et `post-processor-storage`. Le pipeline S3 \u002F clamd \u002F ré-encodage vit dans le package d'infrastructure storage.\n16. **La table d'événements s'appelle `event_queue`**, pas `event_outbox`.\n\n---\n\n## Recette : rattacher une image à un nouveau type d'entité\n\nExemple : ajouter une image à une \"organisation\".\n\n1. **`proto-registry`** (skill `volontariapp-proto-contract-evolution`) :\n   - nouvelle valeur `EntityType` (additive) ;\n   - numéros de champs fixés au-dessus du maximum actuel, `reserved` sur tout numéro supprimé sans réservation ;\n   - `optional string \u003Cx>_file_id` dans l'entité, et dans la commande de création avec `string idempotency_key` ;\n   - pour une mise à jour par `Entity` + `update_mask`, le champ va **uniquement** dans l'entité ;\n   - statut média (enum préfixé, `_UNSPECIFIED = 0`) si le mode est ASYNC ;\n   - **STOP**.\n2. **`npm-packages`** (skill `volontariapp-shared-npm-package-change`) :\n   - `domain-storage` : entrée dans `VALIDATION_POLICY_BY_ENTITY` (mode, taille, MIME, format, maximum par entité) et préfixe de clé publique ;\n   - `messaging` : événements `\u003Cdomaine>.\u003Cx>_replaced` `{ \u003Centity>Id, newFileId?, oldFileId? }` et, si absents, `created` \u002F `deleted` ; payloads de fallback avec l'id calculé ;\n   - `shared` : streams correspondants ;\n   - `domain-\u003Cx>` : id fourni à la création, colonne `\u003Cx>_file_id` (+ statut si ASYNC), écriture de l'entité et de l'événement dans la même transaction, lecture de l'ancien `file_id` sous `SELECT ... FOR UPDATE` lors d'un remplacement ;\n   - **STOP**.\n3. **Service métier** : calcul de l'id, appel `ConfirmFileAttachment` via le client storage partagé avant `withFallback`, statut initial selon le `scan_status` renvoyé. Décider si l'entité supporte le rattachement asynchrone (elle doit alors avoir un statut média) ou répond 503 quand `ms-storage` est indisponible.\n4. **`post-processor-storage`** (skill `volontariapp-implement-async-event-flow`) : consommer création, remplacement, suppression et échec de saga de l'entité. Les échecs de saga (`*.creation_failed`) sont émis par `ws-service` sur les streams partagés `ws:*-created-feedback` : filtrer par type.\n5. **Si l'entité a un statut média** : le post-processor du domaine consomme `storage.file_scanned` \u002F `storage.file_rejected` \u002F `storage.attachment_rejected`, filtre sur `entityType`, ignore les `fileId` qui ne sont plus courants, met à jour le statut de façon idempotente, acquitte avec `warn` si l'entité a été supprimée.\n6. **`api-gateway`** : relayer `\u003Cx>FileId` et `idempotencyKey`, exposer l'URL calculée par `buildPublicFileUrl`.\n7. **`nativapp`** : réutiliser `useUploadFile(entityType)`, générer une `idempotencyKey` par formulaire, gérer `SCANNING` \u002F `REJECTED` si ASYNC.\n8. **Suppression de compte** : décider si les fichiers de ce type sont libérés par `user.deleted` (par défaut oui, sauf ressources de la plateforme comme `BADGE_ICON`).\n\n---\n\n## Checklist de revue\n\n- [ ] Aucun `any`, aucun `as unknown as`, aucun cast sur les erreurs gRPC (type guard dans le client storage partagé).\n- [ ] Aucune duplication du pipeline entre `ms-storage` (SYNC) et `worker-storage` (ASYNC), ni de la persistance `files`.\n- [ ] Codes d'erreur conformes à `09-securite.md` (aucun 500 pour un fichier invalide, `NOT_FOUND` pour le fichier d'un autre).\n- [ ] Toute transition est un `UPDATE` conditionnel, idempotent et loggé via `@volontariapp\u002Flogger`.\n- [ ] Les deux ordres \"scan puis rattachement\" et \"rattachement puis scan\" sont couverts par des tests, pour les chemins de rattachement synchrone et asynchrone.\n- [ ] Les scénarios de `11-scenarios-de-panne.md` concernés par le changement sont testés (clamd arrêté, `ms-storage` arrêté).\n- [ ] Mocks dans `*.mock.ts`, factories dans `*.factory.ts`, `jest.spyOn` + `restoreAllMocks()`.\n- [ ] Noms de fichiers et de répertoires en kebab-case.\n\n## Diagnostic\n\n- Fichier bloqué en `SCANNING` : vérifier `jobs_outbox` \u002F `job_audit` de `ms_storage` (skill `volontariapp-trace-async-flow`). La purge relance un job après 2 h et rejette après 12 h.\n- Fichier resté `RESERVED` : l'événement de confirmation n'a pas été émis (entité jamais écrite, fallback en attente) ou `pp-storage` ne le consomme pas. Libéré automatiquement après 7 jours.\n- Entité restée `PENDING` alors que le fichier est `CLEAN` : vérifier que le fichier est bien `ATTACHED`, puis que `storage.file_scanned` a été écrit dans `event_queue`.\n- Qui produit \u002F consomme un événement : `analyze_impact({ target: \"storage.file_scanned\" })` (mesh-mcp).\n- Qui appelle `StorageService` : `analyze_grpc({ target: \"StorageService\" })` (mesh-mcp).\n",{"name":5,"tags":49,"type":50,"paths":51,"title":55,"status":56,"sources":57,"verified":66,"generated":82,"mesh_keys":85,"description":7},[27,35,38],"Agent Skill",[52,53,54],"docs\u002Fstockage-fichiers\u002F**","npm-packages\u002Fpackages\u002Fdomain-storage\u002Fsrc\u002F**","ms-storage\u002Fsrc\u002F**","File Storage Flow","stable",[58,62],{"id":59,"title":60,"resource":61},"storage-docs","Architecture du stockage de fichiers","docs\u002Fstockage-fichiers",{"id":63,"title":64,"resource":65},"domain-storage","@volontariapp\u002Fdomain-storage","npm-packages\u002Fpackages\u002Fdomain-storage\u002Fsrc",[67,71,73,75,79],{"at":68,"by":69,"digest":70},"2026-10-16T23:31:30Z","claude-code\u002Fagent","c2af7436c571d364",{"at":72,"by":69,"digest":70},"2026-10-16T23:48:47Z",{"at":74,"by":69,"digest":70},"2026-10-17T00:15:13Z",{"at":76,"by":77,"digest":78},"2026-10-28T19:34:14Z","claude-code\u002Fclaude-sonnet-5-5","3e7dfcb890237eed",{"at":80,"by":69,"digest":81},"2026-11-05T17:34:09Z","da39a3ee5e6b4b0d",{"at":83,"by":84},"2026-10-06T08:22:12Z","claude-code\u002Fclaude-opus-5-5",[27,86,35,87,88],"file_id","confirmfileattachment","quarantine","main","5f8c058d8ae7f950539e8ac94ba9bbf34289b88b",".agents\u002Fskills\u002Fvolontariapp-file-storage-flow","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta","https:\u002F\u002Fgithub.com\u002FVolontariapp","Volontariapp — Application Web\u002FMobile scalabelle construite sur une architecture microservices événementielle (Event-Driven Architecture), implémentant le Trans","2026-11-10 23:40:22",{"source":8,"skillName":5,"commands":97,"prompt":107,"zipAvailable":108},[98,101,104],{"tool":99,"command":100},"npx","npx skills add Volontariapp\u002Fmeta --skill volontariapp-file-storage-flow",{"tool":102,"command":103},"bunx","bunx skills add Volontariapp\u002Fmeta --skill volontariapp-file-storage-flow",{"tool":105,"command":106},"pnpm","pnpm dlx skills add Volontariapp\u002Fmeta --skill volontariapp-file-storage-flow","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-file-storage-flow\u002FSKILL.md",true,[110,116,120],{"path":111,"resourceType":112,"resourceTypeDesc":113,"sizeBytes":114,"sourceHtmlUrl":115},"index.md","REFERENCE","参考文档",619,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-file-storage-flow\u002Findex.md",{"path":117,"resourceType":112,"resourceTypeDesc":113,"sizeBytes":118,"sourceHtmlUrl":119},"log.md",8988,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-file-storage-flow\u002Flog.md",{"path":121,"resourceType":112,"resourceTypeDesc":113,"sizeBytes":122,"sourceHtmlUrl":123},"references\u002Flessons.md",14069,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-file-storage-flow\u002Freferences\u002Flessons.md",[125,147,176,201,222,241],{"skillNo":126,"name":127,"nameZh":6,"description":128,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":129,"categories":130,"keywords":131,"publishedAt":44},"01M6CSW3Y5ZHVBF5BX7S5BAVS0","volontariapp-user-account-deletion","Suppression de compte en deux temps (PENDING_DELETION): requestDeletionWithUserDeleted, finalizeDeletion, fan-out de user.deleted vers les domaines et gather ws. A utiliser pour tout changement de domain-user touchant la suppression, le statut ou le cycle de vie d'un user.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-user-account-deletion\u002FSKILL.md",[],[132,133,134,135,136,137,138,139,140,36,141,142,143,144,145,146,40,33,42],"volontariapp user account deletion","user","account deletion","deletion","cycle","gather","sonnet","pending","de","claude-code","reference","npm","user-account-deletion","src","en",{"skillNo":148,"name":149,"nameZh":6,"description":150,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":151,"categories":152,"keywords":158,"publishedAt":44},"01M6CSVC1ZW30DDFZ8VMEYAAK6","volontariapp-proto-contract-evolution","Modifier un contrat Protobuf de proto-registry (champ, message, enum, RPC) sans casser la compatibilité wire, puis suivre la cascade proto-registry, PR automatique dans npm-packages, publication, consommateurs. À utiliser avant toute édition d'un .proto.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-proto-contract-evolution\u002FSKILL.md",[153],{"categoryNo":154,"categoryName":155,"taxonomyType":156,"taxonomyTypeDesc":157},"01JBSEEDCAT000000000000037","律师与法务","PROFESSION","适用职业",[159,160,161,162,36,163,164,42,165,166,167,168,169,143,170,171,172,173,174,175],"evolution","volontariapp proto contract evolution","grpc","lint","workflows","analyze","rpc","ci","pr","protobuf","message","registry","sans","sync","yaml","playbook","github",{"skillNo":177,"name":178,"nameZh":6,"description":179,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":180,"categories":181,"keywords":185,"publishedAt":44},"01M6CSV99ET2Z9W4W4DAK4CMQP","volontariapp-meta-scripts","Utiliser et faire evoluer la CLI Node de meta (setup, doctor, migrate, certs, repos, bump) et le manifeste repos.json, source unique des repos et unites du workspace.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-meta-scripts\u002FSKILL.md",[182],{"categoryNo":183,"categoryName":184,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[186,187,188,36,189,141,190,191,192,193,194,195,196,197,198,199,200],"Meta","volontariapp meta scripts","repos","migrate","setup","meta-scripts","json","node","workspace","shell","bump","cli","doctor","draft","install",{"skillNo":202,"name":203,"nameZh":6,"description":204,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":205,"categories":206,"keywords":210,"publishedAt":44},"01M6CSTPHNTGE0WF5CPE8W7ZYQ","volontariapp-implement-async-event-flow","Guide pas-à-pas pour concevoir et implémenter un flux asynchrone complet (messaging -> outbox -> post-processors -> ws-service -> client).","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-event-flow\u002FSKILL.md",[207],{"categoryNo":208,"categoryName":209,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000009","前端开发",[211,212,213,24,32,214,145,215,216,170,217,218,33,219,220,42,43,34,36,221],"async","implement","volontariapp implement async event flow","messaging","mapping","flux","streams","flows","shared","outbox","runner",{"skillNo":223,"name":224,"nameZh":6,"description":225,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":226,"categories":227,"keywords":231,"publishedAt":44},"01M6CSRQHS0T9955M8CTA5BYJQ","research","Investigate a question against high-trust primary sources and capture the findings as a Markdown file in the repo. Use when the user wants a topic researched, docs or API facts gathered, or reading legwork delegated to a background agent.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fplugins\u002Fmatt-pocock\u002Fskills\u002Fresearch\u002FSKILL.md",[228],{"categoryNo":229,"categoryName":230,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000021","资料检索与研究",[224,232,133,25,233,234,235,236,237,238,239,240],"api","repo","markdown","docs","capture","use","question","trust","investigate",{"skillNo":242,"name":243,"nameZh":6,"description":244,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":245,"categories":246,"keywords":250,"publishedAt":44},"01M6CSRMZ82M1YK96Z4SW1STTD","prototype","Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fplugins\u002Fmatt-pocock\u002Fskills\u002Fprototype\u002FSKILL.md",[247],{"categoryNo":248,"categoryName":249,"taxonomyType":156,"taxonomyTypeDesc":157},"01JBSEEDCAT000000000000032","设计师",[243,251,133,252,237,253,238,254,255,256,257,258],"check","ui","look","design","explore","model","build","state"]