Garde-fous & runbook

Le moteur stock / réappro écrit des mouvements qui engagent de l'argent réel : un ledger qui dérive produit des bons fournisseurs sous-commandés, des ruptures ou des sur-commandes silencieuses. La plateforme superpose donc trois couches de défense (invariants audités, tests épinglés, replay-diff en production) plus une sonde de santé quotidienne. Cette page est la référence opérationnelle : ce que chaque couche garantit, les commandes exactes à lancer, et les arbres de décision pour les trois incidents les plus fréquents.

Les trois couches en un coup d'œil

Couche Outil Quand l'utiliser
1. Invariants sur les données tâche CLI stock-ledger-audit (I1…I7) Sonde quotidienne, gate CI (--strict), réparation (--apply)
2. Régression sur le code tests unitaires épinglés (packages/tests) À chaque commit touchant le ledger ; chaque nouveau bug ajoute un cas
3. Dérive de comportement replay-supplier-orders.ts (diff read-only en prod) Avant de merger tout changement dans le blast radius réappro

Les couches sont complémentaires : la couche 1 attrape les données cassées (quel que soit le code qui les a cassées), la couche 2 attrape le code qui régresse (avant qu'il ne touche les données), la couche 3 attrape les changements de comportement que ni un invariant ni un test unitaire ne formulent — « ce bon fournisseur aurait 12 lignes de plus avec ma branche ».

Couche 1 — Invariants : stock-ledger-audit

La tâche codifie les invariants que le ledger DOIT satisfaire. Elle est née de l'investigation « Bio Rennes » de mai 2026 (stock fantôme sur des produits non suivis, bons fournisseurs silencieusement sous-commandés, cron de réconciliation en échec muet pendant des semaines) et s'est enrichie à chaque incident depuis.

Les trois modes

# Rapport (lecture seule, exit 0 quoi qu'il arrive)
cd apps/app && bun run cli --task stock-ledger-audit \
  --tenantId <tenantId> --siteId <siteId>

# Gate CI / monitoring — exit non-zéro à la moindre violation
cd apps/app && bun run cli --task stock-ledger-audit \
  --tenantId <tenantId> --siteId <siteId> --strict

# Réparation — n'écrit QUE sur les lignes identifiées comme cassées
cd apps/app && bun run cli --task stock-ledger-audit \
  --tenantId <tenantId> --siteId <siteId> --apply

Les invariants

ID Invariant --apply répare ?
I1 Aucun produit avec Product.stock < 0 (le miroir affiché est bridé à 0 — voir le ledger) oui
I2 Aucun produit manageStock=false avec stock ≠ 0 (le cas Bio Rennes) oui
I3 Aucune projection PENDING SUPPLIER_DELIVERY périmée — reliquats de commandes annulées, ou bons SENT/CONFIRMED sans réception depuis ≥ stalePendingDays (défaut 14 j) oui (annulées + envois en retard)
I4 Aucune StockAction dont le referenceId pointe vers un bon fournisseur supprimé oui
I5 Aucun produit suivi où Product.stock ≠ Σ StockActions COMPLETED — sous STOCK_LEDGER_ALLOW_BACKORDER, l'égalité devient stock == max(0, Σ) oui — en prod (backorder ON) : resync du seul miroir Product.stock, aucune SA (le cache stockLedger est le job d'I5b) ; en legacy (backorder OFF) : SA de réconciliation « Backfill »
I5b Product.stockLedger (la colonne cache que trustedStock lit réellement) ≠ Σ COMPLETED — attrape la dérive cache-seule que I5 ne voit pas oui (recale le cache)
I6 Aucun DECREMENT de fulfillment « signature clamp » (quantityChange=0 sur une livraison) — la consommation perdue qui fabriquait du stock fantôme avant #2886 non (le fix est le code, pas les données)
I7 (info-only) ProductVariation.stock ≠ Σ des SA COMPLETED portées par la variation — dérive au niveau variation non — jamais (voir ci-dessous)

Quatre subtilités qui évitent des fausses alertes :

  • I3 ne touche jamais les brouillons. Les projections PENDING de bons DRAFT sont la file de relecture du marchand (le cron met en scène des brouillons, le marchand relit avant envoi). Elles sont rapportées en info, jamais auto-annulées.
  • I5 sous backorder. Depuis le cutover STOCK_LEDGER_ALLOW_BACKORDER (2026-07-09), un Σ négatif est légitime (précommande) ; I5 compare le miroir bridé, I5b épingle le cache brut.
  • I6 est gaté par le cutover. La dette historique (clamps antérieurs) est rapportée en info-only ; seuls les clamps postérieurs à STOCK_LEDGER_BACKORDER_CUTOVER font échouer --strict — tout nouveau clamp est une régression du code, à traiter comme telle.
  • I7 est purement informatif — jamais gaté, jamais réparé. Le stock des variations est hors du modèle ledger : la frontière de confiance et le calcul de réappro lisent le produit uniquement, I5/I5b se restreignent délibérément à productVariationId: null, et aucun cron n'écrit de ligne variation. I7 mesure donc simplement l'ampleur de la dérive qui se cache à ce niveau (nombre de variations concernées + |dérive| totale). Il ne fait jamais échouer --strict et --apply ne le répare jamais : recaler un compteur de variation sur un Σ que rien ne maintient serait le mauvais correctif.
Attention

Limites de portée de l'audit — les angles morts, et ce qui les couvre désormais :

  1. Le stock des variations n'est plus un angle mort silencieux : I7 le rapporte depuis 2026-07 — mais en info-only (voir ci-dessus). L'audit gate reste au niveau produit.
  2. Les produits inactifs sont hors périmètre : I5/I5b filtrent active: true.
  3. Historiquement, la colonne cache stockLedger n'était pas vérifiée du tout ; I5b comble ce trou depuis 2026-07 — mais uniquement pour les produits actifs et suivis.

Si un incident implique une variation ou un produit inactif, un audit « vert » ne prouve rien : vérifiez à la main via la timeline du produit.

Couche 2 — Tests de régression épinglés

Chaque bug de ledger découvert en production devient un cas de test épinglé : le test rejoue les données réelles de l'incident (anonymisées via un snapshot) et échoue si le code re-dérive un jour vers le comportement fautif. Les quatre piliers :

Fichier (packages/tests/tests/unit/) Ce qui est épinglé
supplier-order/stock-ledger-trust-boundary.test.ts 77 cas — la frontière de confiance trustedStock : stock fantôme Bio Rennes réel, négatifs #2725, kill-switch pessimiste #2728, plafond 8 semaines, table de bordure (stock, manageStock) → trustedStock
stock/stock-write-math.test.ts La math pure de recordPhysicalMovement (packages/services/src/stock/rolling-stock-math.ts) : signe du ledger, miroir bridé max(0, ledger), backorder
stock/inventory-count-finalize.test.ts La finalisation d'un comptage physique calcule ses deltas contre le ledger signé et saute manageStock=false
stock/bulk-delete-stock-actions.test.ts La suppression en masse d'actions reverse le ledger et restaure stock = max(0, ledger reversé) sous backorder
# Toute la suite stock (rapide, < 5 s)
cd packages/tests && npm test -- run tests/unit/stock/

# La frontière de confiance seule
cd packages/tests && npm test -- run tests/unit/supplier-order/stock-ledger-trust-boundary.test.ts
Astuce

La règle : un bug = un cas épinglé. Quand vous corrigez un bug de ledger, capturez d'abord la tranche de données affectée (PII retirées au snapshot), committez la fixture à côté du test qui la consomme, puis écrivez le cas qui échoue sans le fix :

bun packages/scripts/src/dev/snapshot-tenant-stock.ts \
  --tenant-id <tenantId> --site-id <siteId> [--vendor-id <uuid>] \
  --days 30 --out packages/tests/fixtures/stock-ledger/<nom>.json

Couche 3 — Replay-diff en production (read-only)

replay-supplier-orders.ts exécute les chemins de production computeExpectedOrders() et getCurrentOrders() avec le code de la branche courante contre la base live, puis imprime la dérive par fournisseur : bons nouveaux / modifiés / orphelins, lignes ajoutées / changées / retirées. Zéro écriture — utilisable contre la prod à tout moment.

bun packages/scripts/src/dev/replay-supplier-orders.ts \
  --tenant-id <tenantId> --site-id <siteId> \
  [--vendor-id <uuid>] \
  [--from 2026-07-01] [--to 2026-07-31]

Lancez-le avant de merger tout changement dans le blast radius réappro :

  • packages/services-supplier-order/src/utils/auto-supplier-order-utils-new.ts
  • packages/services-supplier-order/src/utils/supplier-order-utils-new.ts (la frontière trustedStock)
  • packages/services-supplier-order/src/utils/supplier-order-reconciliation.ts
  • packages/services-supplier-order/src/utils/auto-supplier-orders-regeneration.ts
  • packages/services/src/stock/stock-actions.service.ts
  • tout code qui touche Product.stock ou le cycle de vie d'une StockAction

L'interprétation est simple : si votre branche ne change pas la logique métier, le diff doit être identique à celui de master au même instant. Un delta inattendu (ex. « −33 lignes sur ce fournisseur ») est exactement la classe de régression qu'aucun test unitaire ne formulait — le reconcile de 07:15 l'aurait appliquée en silence le lendemain du merge.

La sonde quotidienne (06:30)

La tâche supplier-stock-health tourne chaque matin à 06:30 (drop-in /etc/cron.d/supplier-stock-health, lecture seule) — juste avant le reconcile de 07:15, pour photographier l'état avant la passe corrective du jour. Son wrapper est le modèle du genre : il exécute le fichier de la tâche directement et termine par exit $EXIT_CODE — l'échec remonte réellement à l'alerting.

Ce qu'elle évalue (verdict global ✅ SAIN / 🛑 ANOMALIE, persisté en KV pour la tour de contrôle) :

Signal Attendu Sens
reconcile.gateAccuracyPct élevé, stable Part des lignes fournisseur déjà conformes à l'état recalculé, net des orphelins à créneau passé. Une chute brutale (85 % → 6 % observé sur #2806) = un changement de logique vient de réécrire l'order-book
Réceptions fantômes ≈ 0 (seuil maxPhantoms, défaut 50) Projections PENDING de brouillons à créneau passé jamais envoyés — le sweep doit les drainer
File supplier-orders-recalc ok Santé de la chaîne événementielle (producteurs → queue bext → recalc sous write-lock)
backdatedDraftsCreated7d 0 depuis la garde Brouillons créés sur créneau passé — doit rester nul sous SUPPLIER_NO_PAST_SLOTS
autoValidateSealed24h tend vers 0 Voir ci-dessous
Précision reconcile minAccuracy (défaut 50 %) Exit non-zéro en dessous — la sonde sert aussi de gate pré-merge
cd apps/app && bun run cli --task supplier-stock-health \
  --tenantId <tenantId> --siteId <siteId> --json

autoValidateSealed24h — le compteur d'oublis

Depuis #2886, le geste primaire de décrément est le scellé du marchand au moment physique (clic de livraison dans l'UI, ou « Valider la tournée du jour ») ; le cron auto-validate (12:01 + balayage toutes les 2 h) n'est plus qu'un filet de sécurité qui scelle ce qui a été oublié — voir les chemins d'écriture. autoValidateSealed24h compte les livraisons que le filet a dû sceller dans les dernières 24 h. Un non-zéro ponctuel est normal ; un non-zéro persistant signifie que les opérateurs ne scellent pas dans l'UI — le ledger reste juste, mais avec de la latence : une fois la grâce AUTO_VALIDATE_GRACE_HOURS=12 franchie, le balayage 2-horaire scelle l'oubli dans les ~2 h, et comme la passe de ~06:01 tourne avant le reconcile de 07:15, le réappro du matin voit normalement bien les livraisons de la veille.

Runbook

Trois arbres de décision pour les incidents récurrents. Dans chaque cas : diagnostiquer en lecture seule d'abord, réparer ensuite, épingler un test à la fin si un chemin de code est fautif.

« Le stock d'un produit semble faux »

flowchart TD
  A["Stock affiché suspect"] --> B{"manageStock ?"}
  B -->|non| C["Convention : produit non suivi ⇒ 0<br/>trustedStock renvoie 0, rien à réparer"]
  B -->|oui| D["Ouvrir la timeline produit<br/>/manage/ecommerce/inventory/stock-ledger"]
  D --> E{"Ledger négatif ?"}
  E -->|oui| F["Backorder réel — la prochaine<br/>réception le nette : normal"]
  E -->|non| G["stock-ledger-audit en mode rapport"]
  G --> H{"I5 / I5b en dérive ?"}
  H -->|oui| I["--apply — prod (backorder ON) : resync du miroir stock, aucune SA<br/>legacy (backorder OFF) : SA Backfill + recale stockLedger"]
  H -->|non| J{"Mouvement fautif visible<br/>dans la timeline ?"}
  J -->|oui| K["Chemin d'écriture hors ledger :<br/>violation règle 17 → fix code + test épinglé"]
  J -->|non| L["Comptage physique<br/>/manage/ecommerce/inventory/counts"]

Pas à pas :

  1. Timeline d'abord. /manage/ecommerce/inventory/stock-ledger → produit → timeline. Le mouvement divergent se voit presque toujours à l'œil (un delta sans réception en face, un ADJUSTMENT inattendu, une réception en double).
  2. Audit en rapport (lecture seule) pour qualifier : dérive I5 (compteur ≠ Σ) ou I5b (cache seul) ?
  3. --apply ne réécrit que les lignes cassées. En prod (backorder ON), I5 resynchronise le seul miroir Product.stockaucune SA (le recalage du cache stockLedger revient à I5b) ; en legacy (backorder OFF), il émet une SA de réconciliation « Backfill ». Le stock redevient cohérent avec l'historique — il ne devient pas physiquement juste pour autant : si le doute physique persiste, faites un comptage.
  4. Si un chemin de code a contourné le ledger (écriture de Product.stock sans StockAction dans la même transaction), c'est une violation de la règle d'or : corrigez le code et ajoutez un cas à stock-write-math.test.ts ou à la frontière de confiance.

« Une commande fournisseur manque des lignes »

Rappel du modèle : une ligne « manquante » signifie que le moteur croit la demande déjà couverte — soit par du stock (trop) crédité, soit parce que le produit est hors du périmètre du cron qui a généré le bon. Le kill-switch pessimiste produit l'effet inverse (sur-commande) : ce n'est jamais lui la cause de lignes manquantes.

flowchart TD
  A["Lignes manquantes dans un bon fournisseur"] --> B{"Produit manageStock=false ?"}
  B -->|oui| C["Seul le reconcile de 07:15 remplit<br/>ces produits — vérifier son dernier run"]
  B -->|non| D{"Le stock crédité couvre la demande ?"}
  D -->|stock élevé| E{"Stock fantôme ?"}
  E -->|oui| F["Plafond STOCK_TRUST_MAX_COVER_WEEKS = 8<br/>+ page /manage/ecommerce/vendors/orders/phantom-stock"]
  E -->|non| G["Audit I5 / I5b : ledger gonflé ?<br/>→ --apply puis recalc"]
  D -->|stock plausible| H["replay-supplier-orders<br/>diff read-only sur le fournisseur"]
  H --> I{"Le diff montre des items added ?"}
  I -->|oui| J["Le moteur les voit : le reconcile 07:15<br/>les ajoutera — ou déclencher un recalc"]
  I -->|non| K["Demande réellement couverte —<br/>un lot 100 % quantité 0 est bénin"]

Pas à pas :

  1. manageStock=false ? Le cron auto-supplier (toutes les 30 min) exclut ces produits ; seul le reconcile quotidien de 07:15 (--apply --removeOrphans) les remplit. Si le bon a été regardé avant 07:15, ou si le reconcile a échoué, les lignes n'existent pas encore.
  2. Stock fantôme ? La frontière de confiance plafonne le stock cru à 8 semaines de consommation récente (STOCK_TRUST_MAX_COVER_WEEKS=8) — mais sous ce plafond, un ledger gonflé zère quand même des lignes. La page /manage/ecommerce/vendors/orders/phantom-stock liste les suspects (couverture en semaines par produit).
  3. Replay-diff ciblé (--vendor-id) : si les lignes apparaissent en added dans l'attendu, le moteur est d'accord avec vous — c'est un problème de timing (le prochain reconcile/recalc les posera). Si le diff est vide, la demande est réellement couverte.
  4. Lot entièrement à quantité 0 : ce n'est pas une erreur — c'est la demande couverte, matérialisée à titre informatif.

« Un cron échoue silencieusement »

Le piège structurel — désormais fermé, mais à connaître : bun run cli --task X sort en exit 0 sauf si la tâche throw, donc une tâche qui retourne {success: false} est avalée ; et un wrapper shell sans set -e ni exit $EXIT_CODE final transforme même un vrai exit non-zéro en 0. Résultat historique : l'ordonnanceur n'alertait jamais, et les crons pouvaient « tourner » (ligne de log présente) tout en échouant depuis des semaines.

Les deux moitiés sont corrigées : les trois tâches-gates (auto-validate, auto-supplier-orders-service, supplier-order-email) throw maintenant — sur échec global et dès qu'il reste des échecs par item (après le travail sûr) — et les 24 wrappers propagent leur code de sortie. Le contrat est épinglé par cron-wrappers.shape.test.ts, qui fait échouer tout wrapper avalant son exit code. Le diagnostic ci-dessous reste valable pour les pannes qui ne passent pas par un code de sortie (cron désinstallé, verrou bloqué, box éteinte).

flowchart TD
  A["Soupçon : un cron ne produit plus rien"] --> B["Chip cron-5 sur la page stock-ledger<br/>(heartbeat) + sonde 06:30 en KV"]
  B --> C{"État du chip ?"}
  C -->|vert ≤ 5h| D["Le cron tourne — chercher ailleurs<br/>(regarder « dernière activité »)"]
  C -->|ambre ≤ 12h| E["Plusieurs passes 2-horaires manquées"]
  C -->|rouge > 12h| F["Le cron n'a pas tourné :<br/>panne probable"]
  E --> G["tail des logs ~/.ploi/scheduled-*.log"]
  F --> G
  G --> H{"Erreur dans le log<br/>mais exit code 0 ?"}
  H -->|oui| I["Masquage d'exit-code (régression) :<br/>wrapper sans exit $EXIT_CODE,<br/>ou tâche qui return au lieu de throw"]
  H -->|non| J["Verrou bloqué ? fuser -v sur le lockfile<br/>+ rejouer en dry-run<br/>+ vérifier l'entrée /etc/crontab"]
  I --> K["Pattern correct : wrapper exit $EXIT_CODE<br/>+ tâche-gate qui THROW"]

Pas à pas :

  1. Les chips d'abord. La page /manage/ecommerce/inventory/stock-ledger porte un chip de santé du cron auto-validate. Son titre est la liveness (« le cron a-t-il tourné ? »), lue sur le heartbeat écrit à chaque run : ≤ 5 h vert, ≤ 12 h ambre, > 12 h rouge. L'âge du dernier scellé (réception / livraison) est rétrogradé en info secondaire (« dernière activité ») : un jour calme ne produit aucune ligne, ce n'est pas une panne. Si le chip est rouge alors que la box tourne, penser au verrou orphelin (un bun bloqué qui garde le flock fait sauter toutes les passes suivantes) : fuser -v /tmp/auto-validate-stock-actions.<TENANT_ID>.lock.
  2. Les logs. Chaque wrapper redirige vers ~/.ploi/scheduled-*.log sur la box tenant :
ls -lt ~/.ploi/scheduled-*.log | head
tail -100 ~/.ploi/scheduled-<id>.log
  1. Erreur visible + exit 0 = masquage. Vérifiez le wrapper (scripts/cron/*.sh) : il doit se terminer par exit $EXIT_CODE (modèle : supplier-stock-health.sh), et la tâche utilisée comme gate doit throw en cas d'échec (modèle : reconcile-supplier-orders.ts, qui throw sur toute erreur Prisma par ligne au lieu de l'avaler).
  2. Rejouer à la main en dry-run pour reproduire — chaque cron a une invocation de smoke test documentée dans la timeline quotidienne.
Attention

Ne « réparez » jamais un cron muet en relançant sa version --apply à l'aveugle. D'abord le dry-run, puis l'audit (stock-ledger-audit), puis seulement l'apply — dans cet ordre. Un cron qui a raté trois jours peut avoir accumulé un backlog que le --apply traiterait d'un bloc.

Les commandements (règle 17, condensée)

La liste NEVER/ALWAYS issue des incidents réels — chaque ligne a coûté au moins un incident de production avant d'être écrite.

Ne jamais :

  • Écrire Product.stock sans (a) vérifier manageStock et (b) émettre la StockAction correspondante dans la même transaction — sinon stockLedger décroche (I5/I5b).
  • Réintroduire un décrément de stock à la synchro des commandes. Le chemin a été supprimé le 2026-06-14 : le stock décrémente à la livraison uniquement (scellé UI, filet auto-validate). Un décrément à la synchro double-compterait sans signal d'idempotence partagé.
  • Créer une StockAction PENDING pour un produit manageStock=false — ça pollue le ledger (l'origine du cas Bio Rennes).
  • Passer une SA de PENDING/COMPLETED à CANCELLED sans reverser le mouvement de Product.stock correspondant pour les produits suivis.
  • Stubber quantityBefore=0 à la création d'une SA — lire le vrai Product.stock d'abord.
  • Avaler les erreurs Prisma par item dans une tâche cron — throw, et laisser le monitoring attraper (c'est tout le sujet de l'arbre 3 ci-dessus).

Toujours :

  • Appliquer la frontière de confiance manageStock ? max(0, stock) : 0 (plus plafond consommation) en lisant le stock pour la math de réappro.
  • Ajouter un cas épinglé à stock-ledger-trust-boundary.test.ts (ou au test du chemin d'écriture concerné) pour chaque nouveau cas de bord découvert.
  • Lancer le replay-diff avant de merger un changement du blast radius réappro.
  • Enregistrer tout nouveau point d'entrée de tâche ledger dans le smoke test CLI (packages/tests/tests/unit/cli/cli-smoke.test.ts).

Voir aussi