---
title: "Configuration — le modèle de résolution"
source: docs/architecture/configuration.md
url: https://nodefony.github.io/nodefony-core/docs/architecture/configuration/
---


> Une application Nodefony n'écrit **que ses écarts**. Tout le reste descend des défauts du framework,
> se superpose dans un ordre unique (défauts → projet → environnement), et passe une **validation Zod
> au boot** : une config fautive arrête le démarrage en nommant le champ, elle ne produit jamais un
> `undefined` qui explose trois heures plus tard. Cette page décrit le **modèle** ; le pas-à-pas est
> dans le [guide de configuration](../../guides/configuration/). Ancré sur `src/nodefony/src/config/`.

📍 [Documentation](../../) › **Configuration**

## 🧠 Le modèle mental — quatre couches, une config figée

Une configuration, c'est un empilement de décisions prises par des gens différents : le framework
décide des défauts, tu décides des écarts de ton projet, ton devops décide du déploiement. Le rôle du
moteur est de les empiler **dans un ordre connu**, une fois, et de figer le résultat.

```mermaid
flowchart TD
  D["1 · Défauts du framework<br/>defaultAppConfig + schéma Zod de chaque module"] --> M["deep-merge"]
  A["2 · Config du projet<br/>nodefony.config.ts + use()"] --> M
  SRC["process.env<br/>.env du poste · shell · orchestrateur"] --> CAT["env.ts — defineEnv<br/>catalogue TYPÉ, seul lecteur"]
  CAT -->|"ctx.env"| A
  SRC --> O["3 · Override de déploiement<br/>NF__APP__… · NF__MODULE__… · …_FILE"]
  M --> O
  O --> V{"4 · Validation Zod"}
  V -->|"invalide"| X["boot ARRÊTÉ<br/>champ fautif nommé · code de sortie 78"]
  V -->|"valide"| R["config résolue et figée<br/>plus aucune lecture d'environnement"]
```

Le fait structurant tient en une ligne : **un seul endroit lit `process.env`** — le catalogue
`defineEnv()` (`defineEnv.ts#defineEnv`). Partout ailleurs, la configuration est un objet typé, résolu au
boot. Cela supprime d'un coup toute une famille de pannes : le `process.env.X` lu au fond d'un
service, jamais validé, absent en production.

## 📖 Lexique

| Terme                | Sens                                                                                                         |
| -------------------- | ------------------------------------------------------------------------------------------------------------ |
| **`defineConfig`**   | Le builder de la config d'application. Accepte un objet, ou une fonction `(ctx) => objet`.                   |
| **Descripteur**      | Ce que `defineConfig` retourne : un objet opaque que le Kernel **résout** au boot, pas la config elle-même.  |
| **`ctx`**            | Le contexte passé à la forme fonction : `env`, `infra`, `appEnv`, `runtimeEnv`, `isProd`, `isDev`, `isTest`. |
| **`defineEnv`**      | Le catalogue de variables d'environnement typées — **seul** lecteur de `process.env`.                        |
| **`use()`**          | Déclare un module dans le manifeste `modules`, avec sa config colocalisée et typée.                          |
| **Deep-merge**       | Fusion récursive : les objets se complètent clé par clé, les tableaux se fusionnent case par case.           |
| **Zod**              | La bibliothèque de schémas qui valide **et** type la config. Une source, pas deux.                           |
| **Override env**     | Surcharge d'un champ par une variable `NF__…`, sans toucher au code (métier du devops).                      |
| **`*_FILE`**         | Convention de secret monté : `NF_X_FILE=/run/secrets/x` fait lire le **contenu du fichier**.                 |
| **Infra déclarée**   | Les URLs de base/cache lues de l'environnement, qui pilotent les stores en `"auto"`.                         |
| **Provenance**       | L'origine d'une valeur résolue : défaut, projet, ou environnement. Affichée par Studio.                      |
| **`runtimeMutable`** | Métadonnée de champ : sa valeur est relue à l'usage, donc modifiable sans redémarrer.                        |
| **`env`**            | Métadonnée de champ : le NOM COURT qui le règle (`.meta({ env: "NF_REDIS_HOST" })`) — posé par le Kernel.    |
| **`placement`**      | Métadonnée de champ, avec `env` : où poser la variable en production (`platform`, `secrets`, `workstation`). |
| **12-factor**        | La discipline « la config vit dans l'environnement, le code est identique partout ».                         |

## Qu'est-ce qu'on appelle « la configuration »

Prends une console de mixage. Chaque potentiomètre a une position d'usine ; le studio en règle
quelques-uns pour son local ; l'ingénieur du son en bouge deux pour la salle du soir. Personne ne
reconstruit la console entre deux concerts. C'est exactement le problème : **la même image de code
doit tourner en local, en test et en production**, avec d'autres ports, URLs et secrets.

Trois pièges classiques guettent, et Nodefony répond aux trois par le même mécanisme :

1. **Les défauts éparpillés** — chaque app duplique les valeurs « en dur », donc une correction du
   framework ne l'atteint jamais. → défauts **centralisés** dans le framework.
2. **La config non validée** — un port écrit `"8080"` au lieu de `8080` casse au premier `listen()`,
   loin de la cause. → un schéma qui **valide au boot**.
3. **Les secrets dans le code** — une clé committée est une clé compromise, et l'historique Git ne
   s'oublie pas. → un environnement qui **surcharge sans toucher au code**.

## La vision Nodefony — deux fichiers, un seul lecteur de l'environnement

La config d'une application tient dans **deux fichiers à la racine**, et rien d'autre. Pas de dossier
`config/` à découper, pas de `config.prod.ts` parallèle.

| Fichier              | Rôle                                                                               |
| -------------------- | ---------------------------------------------------------------------------------- |
| `nodefony.config.ts` | Les **écarts** du projet : `defineConfig((ctx) => …)` + le manifeste `modules`.    |
| `env.ts`             | Le **catalogue** des variables d'environnement (`defineEnv`) — seul `process.env`. |

Quatre partis pris, tous vérifiables dans le code :

- **`defineConfig()` ne retourne pas une config, mais un descripteur** (`defineConfig.ts#defineConfig`) : une
  marque privée (`CONFIG_DESCRIPTOR`, `defineConfig.ts#CONFIG_DESCRIPTOR`) et une seule méthode, `resolve(ctx)`,
  appelée par le Kernel au boot (`Kernel.resolveAppOptions()`, `Kernel.ts#Kernel.resolveAppOptions`). Ta config
  **connaît donc son environnement** au moment où elle est calculée.
- **Le par-environnement passe par `ctx`, jamais par un fichier parallèle** (`ConfigContext`,
  `types.ts#ConfigContext.env`). Un `config.prod.ts` séparé diverge silencieusement ; une expression ternaire, non.
- **Les défauts sont dans le framework, pas dans ton projet.** `defaultAppConfig` (`defaults.ts#defaultAppConfig`)
  est deep-mergé **sous** ta config (`mergeAndValidate()`, `defineConfig.ts#mergeAndValidate`) — une amélioration
  du framework te parvient sans que tu ne réécrives rien.
- **Le boot est fail-closed.** `validateAppConfig()` (`schema.ts#validateAppConfig`) agrège les erreurs Zod avec le
  chemin fautif ; l'échec devient un diagnostic présenté puis une sortie dédiée
  (`Kernel.bootConfigError()`, `Kernel.ts#Kernel.bootConfigError`).

> [!IMPORTANT]
> Un fichier de config ne doit **jamais** déréférencer le kernel au moment de son import
> (`Nodefony.getKernel().path` au top-level). Le kernel n'existe pas encore quand le module est
> évalué : l'app crashe à l'import, et devient intestable sans serveur. Le Kernel le dit d'ailleurs
> explicitement dans son diagnostic d'erreur d'entrée (`Kernel.ts#Kernel.loadApp`). La parade est un **getter**
> (résolu à la lecture, au boot) ou `ctx`, qui rend le déréférencement inutile.

## 🚀 Démarrage rapide

**Le besoin.** Tu viens de générer une app avec `nodefony create app`. Elle doit écouter sur le port
imposé par la plateforme en production, garder des logs bavards en développement, et charger l'ORM
uniquement si une base est déclarée. Le tout **sans** deux fichiers de config.

### Les deux fichiers racine

```ts
// ─────────────────────────── env.ts ───────────────────────────
// Catalogue TYPÉ et validé au boot. C'est le seul lecteur de process.env.
import {
  defineConfig,
  defineEnv,
  envEnum,
  envNumber,
  envString,
  use,
} from "nodefony";

export const env = defineEnv({
  // `optional: true` sans défaut → `number | undefined` : absente, le défaut
  // du framework (5151) s'applique. Une valeur non numérique fait échouer le boot.
  NF_PORT: envNumber({
    optional: true,
    description: "Port d'écoute HTTP (défaut framework 5151).",
  }),
  // Ensemble fermé : une faute de frappe (`stout`) est refusée AU BOOT,
  // pas silencieusement ignorée. `as const` est OBLIGATOIRE (voir Pièges).
  NF_LOG_DRIVER: envEnum(["stdout", "file", "null"] as const, {
    default: "stdout",
  }),
  // Infra déclarée : sa présence suffit à aiguiller les stores et à charger l'ORM.
  NF_DATABASE_URL: envString({
    optional: true,
    description: "URL unique de la base (sqlite: | postgres:// | mysql://).",
  }),
});

// ─────────────────── nodefony.config.ts ───────────────────
// Fichier SÉPARÉ dans une vraie app : il ouvre par
//   import { defineConfig, use } from "nodefony";
//   import type { env } from "./env";
// Le générique `<typeof env>` est ce qui type `ctx.env` clé par clé.
export default defineConfig<typeof env>((ctx) => ({
  // Par-environnement : une expression, pas un second fichier. Un conteneur
  // doit écouter toutes les interfaces — un bind 127.0.0.1 n'est jamais joignable.
  domain: ctx.isProd ? "0.0.0.0" : "127.0.0.1",
  // On n'écrit le port QUE si l'environnement l'impose : sinon le défaut gagne.
  servers: ctx.env.NF_PORT ? { http: { port: ctx.env.NF_PORT } } : {},
  log: { debug: ctx.isProd ? [] : "*", driver: ctx.env.NF_LOG_DRIVER },
  // Manifeste ORDONNÉ : l'ordre du tableau EST l'ordre de chargement.
  modules: [
    use("@nodefony/http", {}),
    "@nodefony/framework",
    // Chargé seulement si une base est déclarée — un module non listé
    // n'est même pas importé (0 coût, pas seulement 0 usage).
    use("@nodefony/drizzle", {}, { when: () => !!ctx.infra.database }),
  ],
}));
```

### Ce qu'on observe

```bash
# 1) Sans rien déclarer : les défauts du framework s'appliquent.
npx nodefony development
#   → écoute sur 127.0.0.1:5151, logs debug complets, drizzle NON chargé

# 2) Une variable du catalogue change le comportement, sans toucher au code.
NF_DATABASE_URL='postgres://user:pwd@db:5432/app' npx nodefony development
#   → drizzle chargé (`when` satisfait), stores "auto" aiguillés vers postgres

# 3) Une valeur HORS de l'énumération arrête le boot en la nommant.
NF_LOG_DRIVER=stout npx nodefony development
#   [nodefony] Variables d'environnement invalides : NF_LOG_DRIVER: Invalid option…

# 4) Le devops surcharge n'importe quel champ ayant un défaut, sans catalogue.
NF__APP__SERVERS__HTTP__PORT=8080 NF__APP__LOG__DRIVER=file npx nodefony production
```

Trois recettes pour faire grandir ce fichier (ajouter un module, extraire un bloc devenu gros,
conditionner une entrée) sont détaillées dans le [guide](../../guides/configuration/).

## ⚙️ L'ordre de superposition — et ses trois gardes

L'échelle est unique et vaut pour l'app comme pour les modules : **le plus bas perd, le plus haut
gagne**.

| #   | Couche               | Qui décide | Où ça se joue                                                                                                                        |
| --- | -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | Défauts du framework | Nodefony   | `defaultAppConfig` (`defaults.ts#defaultAppConfig`) · le `.default()` du schéma Zod de chaque module                                 |
| 2   | Config du projet     | toi        | `nodefony.config.ts` — deep-merge `extend(true, {}, …)` (`defineConfig.ts#CONFIG_DESCRIPTOR`)                                        |
| 3   | Déploiement          | le devops  | `NF__APP__…` (`defineConfig.ts#applyAppEnvOverrides`) · `NF__<MODULE>__…` (`Kernel.ts#Kernel.applyModuleConfigOverrides`) · `*_FILE` |
| 4   | Invocation           | la CLI     | drapeaux de commande (`--workers`, …)                                                                                                |

L'override d'environnement est appliqué **entre le merge et la validation** : la valeur venue du
déploiement est donc validée par le même schéma que le reste. Un devops ne peut pas injecter une
valeur que ton code n'aurait pas acceptée — c'est le sens de « fail-closed ».

Trois gardes évitent les heures de débogage les plus classiques :

- **Un override n'écrit que sur un chemin DÉJÀ présent.** `applyResolvedPath()`
  (`envOverride.ts#applyResolvedPath`) résout chaque segment contre les clés **réelles** (insensible à la casse,
  `resolveKey()`, `envOverride.ts#resolveKey`) et renvoie `false` si le chemin n'existe pas. Aucune clé
  fantôme n'est créée. À la place, un avertissement « vouliez-vous dire… » façon Git, calculé par
  distance d'édition (`closestMatch()`, `envOverride.ts#closestMatch`), monté en message par
  `resolveFailureHint()` (`envOverride.ts#resolveFailureHint`).
- **La coercion est explicite.** `coerceEnvValue()` (`envOverride.ts#coerceEnvValue`) traite `"true"`/`"false"`,
  les nombres, le JSON (`[…]`, `{…}`) et le CSV. Le piège `z.coerce.boolean("false") === true` est
  ainsi évité, et une chaîne vide compte comme **absente** (`isAbsent()`, `defineEnv.ts#isAbsent`).
- **Le schéma de l'app n'est pas strict.** Les clés inconnues (`module-<x>`, `App`, `cluster`) sont
  **ignorées, pas rejetées** (`schema.ts`) : chaque module valide **son** bloc avec **son** schéma.
  Une seule autorité par périmètre.

> [!WARNING]
> Un champ **opt-in sans défaut framework** (`domainCheck`, `domainAlias`) n'est pas surchargeable
> par `NF__APP__*` tant que ton `nodefony.config.ts` ne le déclare pas : le chemin n'existe pas dans
> l'objet fusionné, donc l'override est refusé et signalé. Ce n'est pas un bug, c'est la garde n°1
> qui fait son travail — déclare la clé (même à sa valeur neutre) pour la rendre adressable.

## 🧰 Le catalogue d'environnement — `defineEnv` et ses helpers

`defineEnv()` (`defineEnv.ts#defineEnv`) lit la source **une fois**, valide tout en bloc, et renvoie un
objet **gelé** (`Object.freeze`, `defineEnv.ts#defineEnv`). Une variable absente prend son défaut ; une
variable présente mais invalide **arrête le boot en la nommant** (`defineEnv.ts#isEnvVarRequired`).

Il déclare aussi ses propres métadonnées (`getEnvCatalog()`, `defineEnv.ts#getEnvCatalog`), ce qui permet de
**générer** `.env.example` depuis le catalogue (`renderEnvExample()`, `envExample.ts#renderEnvExample`) au lieu de
le maintenir à la main — un fichier d'exemple qui ment est pire que pas d'exemple.

| Helper         | Type produit                        | Absente ⇒                | Refusé au boot                              |
| -------------- | ----------------------------------- | ------------------------ | ------------------------------------------- |
| `envString()`  | `string` (ou `string \| undefined`) | défaut, sinon **erreur** | rien (toute chaîne passe)                   |
| `envNumber()`  | `number`                            | défaut, sinon **erreur** | valeur non numérique                        |
| `envBoolean()` | `boolean`                           | défaut (`false`)         | tout ce qui n'est ni vrai ni faux 12-factor |
| `envEnum()`    | l'union littérale exacte            | défaut, sinon **erreur** | toute valeur hors de la liste               |

### `envString()` — la chaîne, requise ou non

Trois régimes selon les options (`defineEnv.ts#envString`) : avec `default` la variable est toujours
présente ; avec `optional: true` le type devient `string | undefined` ; sans ni l'un ni l'autre elle
est **requise**, et son absence arrête le boot. C'est le helper des URLs et des secrets.

### `envNumber()` — le nombre, coercé puis vérifié

`envNumber()` (`defineEnv.ts#envNumber`) convertit puis laisse Zod trancher : une valeur non numérique est
transmise **brute** au schéma, qui la rejette avec le nom de la variable. Un port mal orthographié ne
devient jamais `NaN` silencieusement.

### `envBoolean()` — les ensembles 12-factor

`envBoolean()` (`defineEnv.ts#envBoolean`) accepte `1/true/yes/on` et `0/false/no/off`, insensible à la
casse (`TRUTHY`/`FALSY`, `defineEnv.ts#FALSY`). Tout le reste est une **erreur** : `tru` est une faute
de frappe, pas un « faux » implicite. Ce helper a toujours une valeur (défaut `false`).

### `envEnum()` — l'ensemble fermé, littéral préservé

`envEnum()` (`defineEnv.ts#envEnum`) est le seul qui rende le type **exact** (`"stdout" | "file" |
"null"`), ce qui permet de le brancher directement sur un champ de config qui attend cette union.
C'est le helper des molettes : driver de log, mode, dialecte.

> [!TIP]
> Le `as const` sur la liste de valeurs n'est pas cosmétique : sans lui, TypeScript élargit l'union
> en `string`, et le champ ciblé (`log.driver`) refuse la valeur. C'est l'erreur de typage la plus
> fréquente sur `env.ts`.

### Les secrets — la convention `*_FILE`

`resolveFileEnv()` (`defineEnv.ts#resolveFileEnv`) implémente la convention des secrets montés : si `NF_X` est
absente mais que `NF_X_FILE` pointe un fichier (secret Docker, `Secret` Kubernetes, Vault), c'est le
**contenu du fichier** qui est lu, retour à la ligne final retiré. Deux règles fermes :

- déclarer `NF_X` **et** `NF_X_FILE` en même temps est une **ambiguïté** → `resolveFileEnv()` lève
  une erreur explicite (`defineEnv.ts#resolveFileEnv`) ;
- un fichier illisible est une erreur de boot, jamais un repli silencieux (`defineEnv.ts#ENV_META`).

Côté journal, les chemins qui ressemblent à un secret sont détectés (`pathLooksSecret()`,
`envOverride.ts#pathLooksSecret`) et leur valeur est **rédigée** par `Kernel.surfaceAppEnvOverrides()`
(`Kernel.ts#Kernel.surfaceAppEnvOverrides`).

Le fichier `.env` est chargé **avant** le boot par `loadEnv()` (`loadEnv.ts#loadEnv`) — le seul fichier
lu : les valeurs du poste, jamais commité ; sa notice `.env.example` n'est jamais chargée, et la
production n'a aucun fichier ([ADR-0014](https://github.com/nodefony/nodefony-core/blob/main/docs/adr/0014-fichiers-environnement.md)). **Rien**
n'écrase une variable déjà posée dans `process.env` par le shell ou l'orchestrateur
(`loadEnv.ts#loadEnv`).

## 🧩 Déclarer un module — `use()`, le manifeste et son typage

Le tableau `modules` **est** la liste des modules chargés, dans l'ordre. Trois formes coexistent :

| Forme                                 | Signification                                          |
| ------------------------------------- | ------------------------------------------------------ |
| `"@nodefony/http"`                    | chaîne nue — équivaut à `{ name, policy: "optional" }` |
| `{ name, policy, when }`              | entrée détaillée, sans config                          |
| `use(name, config, { policy, when })` | entrée **+ config colocalisée et typée**               |

`use()` (`use.ts#use`) ne fait rien de magique : il construit l'entrée `{ name, config?, policy?,
when? }`. Toute son intelligence est dans le **type**. Le générique capture le nom au point d'appel
et contraint la config à `ConfigOf<N>` (`use.ts#ConfigOf`), résolue via un registre **augmentable**,
`NodefonyModuleConfig` (`use.ts#NodefonyModuleConfig`).

### Pourquoi un registre plutôt qu'un type central

Un type central obligerait le cœur à connaître tous les modules — y compris ceux qui n'existent pas
encore. Le registre inverse la dépendance : chaque module **s'ajoute lui-même** par fusion de
déclarations (le patron de Nuxt et Pinia), donc un module tiers en bénéficie autant qu'un module du
dépôt.

```ts ignore
// dans @nodefony/drizzle/index.ts — l'augmentation tient en 5 lignes
declare module "nodefony" {
  interface NodefonyModuleConfig {
    "@nodefony/drizzle": IDrizzleConfigInput;
  }
}
```

Vérifié au source : `drizzle/index.ts`, `security/index.ts`, `http/index.ts`. Sans
augmentation, `use()` accepte quand même la config (`Record<string, unknown>`) — jamais bloquant,
simplement sans auto-complétion.

### Le filtrage — `policy` et `when`

`UseOptions` (`use.ts#UseOptions.policy`) porte deux leviers qui **filtrent** sans jamais réordonner
(`Kernel.resolveModuleEntries()`, `Kernel.ts#Kernel.resolveModuleEntries`) :

- **`policy: "dev"`** → l'entrée est retirée quand le runtime est `production` (`Kernel.ts#Kernel.resolveModuleEntries`) ;
- **`when(config)`** → une garde évaluée sur la config résolue ; `false` retire l'entrée
  (`Kernel.ts#Kernel.resolveModuleEntries`).

Un module retiré n'est pas « chargé puis désactivé » : il n'est **jamais importé**. En ESM, un module
non importé n'existe pas — le gain est réel, en mémoire comme en temps de boot. Les entrées écartées
sont tout de même journalisées avec leur raison (`Kernel.recordModuleGated()`, `Kernel.ts#Kernel.recordModuleGated`), pour
qu'un module absent reste explicable.

## ⚙️ Mises en situation — varier sans dupliquer

Quatre besoins réels, la configuration qui y répond, et le comportement observable.

### Situation 1 — « je veux une valeur différente en production »

Ton serveur doit écouter `127.0.0.1` en local (rien ne fuit hors de ta machine) et `0.0.0.0` en
conteneur (sinon le mappage de ports ne l'atteint jamais).

```ts ignore
export default defineConfig((ctx) => ({
  domain: ctx.isProd ? "0.0.0.0" : "127.0.0.1",
  log: { debug: ctx.isProd ? [] : "*" },
}));
```

| `NODE_ENV` vaut… | `ctx.runtimeEnv` | `domain` résolu | logs debug |
| ---------------- | ---------------- | --------------- | ---------- |
| `development`    | `development`    | `127.0.0.1`     | tous       |
| `dev`            | `development`    | `127.0.0.1`     | tous       |
| `test`           | `test`           | `127.0.0.1`     | tous       |
| `production`     | `production`     | `0.0.0.0`       | aucun      |
| (absent)         | `production`     | `0.0.0.0`       | aucun      |

Deux détails qui comptent : `dev` est normalisé en `development` (`Kernel.ts#Kernel.buildConfigContext`), et **l'absence**
de `NODE_ENV` est traitée comme `production` — le défaut est le régime le plus prudent, jamais le plus
bavard.

> [!TIP]
> `ctx` porte **deux** axes distincts. `runtimeEnv` est le mode moteur (`NODE_ENV`) ; `appEnv` est un
> axe de déploiement libre (`APP_ENV`/`NF_ENV`, `Kernel.ts#Kernel.buildConfigContext`). Un pré-production tourne
> « comme la production » (`isProd` vrai) tout en se distinguant par `ctx.appEnv === "staging"`.
> C'est ce qui évite le faux dilemme « soit c'est prod, soit ça ne l'est pas ».

### Situation 2 — « je dois injecter un secret sans le committer »

Une clé de signature de webhooks ne doit exister ni dans Git, ni dans l'image Docker. Elle est montée
par l'orchestrateur en tant que **fichier**.

```ts ignore
// env.ts — on déclare la variable, jamais la valeur
export const env = defineEnv({
  NF_WEBHOOK_KEY: envString({
    optional: true,
    description: "Clé de chiffrement des secrets de signature webhook.",
  }),
});
```

| Le déploiement fournit…                         | Ce que vaut `env.NF_WEBHOOK_KEY`              |
| ----------------------------------------------- | --------------------------------------------- |
| `NF_WEBHOOK_KEY=abc…`                           | `"abc…"` (variable directe)                   |
| `NF_WEBHOOK_KEY_FILE=/run/secrets/webhook`      | le **contenu** du fichier, sans le saut final |
| les **deux**                                    | **erreur de boot** — ambiguïté refusée        |
| `NF_WEBHOOK_KEY_FILE` vers un fichier illisible | **erreur de boot** nommant le chemin          |
| rien                                            | `undefined` — à la brique de décider          |

En local, la même variable vit dans `.env`, qui est gitignoré (`loadEnv.ts#ENV_FILE`). Un seul
mécanisme, deux contextes.

### Situation 3 — « le devops doit changer un réglage sans redéployer le code »

Ton équipe d'exploitation veut basculer le driver de log en fichier et ouvrir un port différent,
sur une image déjà construite. Aucune ligne de TypeScript ne doit bouger.

```bash
NF__APP__LOG__DRIVER=file            # config APP — segment réservé `app`
NF__APP__SERVERS__HTTP__PORT=8080    # `__` = un niveau de profondeur
NF__HTTP__SESSION__STORE=redis       # config d'un MODULE : `NF__<MODULE>__<CHEMIN>`
NF__SECURITY__CORS__ORIGINS=https://a.com,https://b.com   # CSV → tableau
```

| Ce que le devops écrit              | Effet                                                                                                  |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| un chemin existant, valeur valide   | appliqué, puis **validé par le même Zod** — et journalisé (`Kernel.ts#Kernel.applyEnvConfigOverrides`) |
| un chemin existant, valeur invalide | **boot rejeté** avec le chemin fautif — jamais de surcharge silencieuse                                |
| un segment mal orthographié         | ignoré + « vouliez-vous dire… » + liste des clés réelles (`envOverride.ts#applyResolvedPath`)          |
| un chemin sans défaut framework     | ignoré + suggestion : déclare la clé dans `nodefony.config.ts` d'abord                                 |
| un chemin marqué secret             | appliqué, valeur **rédigée** au log (`envOverride.ts#coerceEnvValueLike`)                              |

Le séparateur `__` vient de .NET et Docker : il ne peut pas être confondu avec le `camelCase` d'un nom
de clé. Les segments sont **insensibles à la casse** et résolus contre les clés réelles, donc
`ACCESSTTLS` retrouve `accessTtlS`.

### Situation 4 — le contre-exemple piégeux : « ma config plante à l'import »

Le symptôme est déroutant : l'app ne démarre pas, la trace pointe l'entrée du programme, et le
message parle d'une propriété lue sur `null`. La cause est presque toujours la même.

```ts ignore
// ❌ INTERDIT — déréférence à l'ÉVALUATION du module : le kernel n'existe pas encore
export default {
  connectors: {
    db: { filename: path.resolve(Nodefony.getKernel().path, "app.db") },
  },
};

// ✅ Getter — résolu à la LECTURE (au boot, kernel présent). Runtime identique.
export default {
  connectors: {
    db: {
      get filename() {
        return path.resolve(Nodefony.getKernel().path, "app.db");
      },
    },
  },
};
```

Le Kernel le suggère lui-même dans le diagnostic d'échec d'import de l'app : « un fichier de config
déréférence le kernel au top-level → différer en getter/lazy » (`Kernel.ts#Kernel.loadApp`). Le coût caché du
premier code n'est pas seulement le crash : le module devient **non importable sans serveur**, donc
non testable. Avec la forme fonction `(ctx) => …`, le problème disparaît — `ctx` porte déjà ce qu'il
faut.

## 🧩 Exposer la config de SON module — la convention deux fichiers

Un module qui se configure porte **exactement deux fichiers**, aux mêmes noms partout. Le module de
référence est `@nodefony/drizzle`.

| Fichier                                 | Rôle           | Contenu                                                                        |
| --------------------------------------- | -------------- | ------------------------------------------------------------------------------ |
| `nodefony/config/config.ts`             | **le QUOI**    | le schéma Zod commenté : source **unique** des types, défauts et documentation |
| `nodefony/config/defineModuleConfig.ts` | **le COMMENT** | un builder pur : analyse l'entrée, superpose l'environnement, gèle             |

Le schéma est la seule source de vérité : le type (`z.infer`), la validation, le **défaut**
(`.default()`), la documentation (`.describe()`) et le formulaire d'administration
(`z.toJSONSchema()`) en dérivent tous. **Un défaut n'est jamais retapé ailleurs** — c'est ce qui rend
impossible la divergence entre la doc et le code.

```ts ignore
// nodefony/config/defineModuleConfig.ts — le builder, ~15 lignes, jamais un défaut
export function defineDrizzleConfig(
  config: IDrizzleConfigInput = {},
): IDrizzleConfig {
  const parsed = drizzleConfigSchema.parse(config);
  return Object.freeze(applyEnvOverrides(parsed));
}
```

Vérifié au source : `drizzleConfigSchema` (`drizzle/nodefony/config/config.ts#drizzleConfigSchema`) et
`defineDrizzleConfig()` (`drizzle/nodefony/config/defineModuleConfig.ts#defineDrizzleConfig`). Le module publie enfin
son JSON Schema en redéfinissant `Module.configSchema()` (`Module.ts#Module.configSchema`), et lit sa config validée
via le getter typé `Module.config` (`Module.ts#Module.config`).

### Les métadonnées de champ — dire ce qu'une valeur EST

Au-delà de la description, un champ porte des **marqueurs opt-in** décrits par `IConfigFieldMeta`
(`configMeta.ts#IConfigFieldMeta.reserved`). Ils ne changent rien au runtime : ils **informent les outils**.

| Marqueur         | Ce qu'il déclare                                | Ce que ça change concrètement                                        |
| ---------------- | ----------------------------------------------- | -------------------------------------------------------------------- |
| `secret`         | donnée sensible                                 | masquée dans Studio, rédigée dans les logs, **jamais** éditable live |
| `runtimeMutable` | valeur relue à chaque usage                     | modifiable à chaud, sans redémarrage                                 |
| `kernelDerived`  | défaut injecté depuis le kernel (chemins, tmp…) | Studio affiche « auto » plutôt qu'une valeur figée                   |
| `reserved`       | prévu, non lu en runtime                        | Studio grise le champ                                                |

Ces marqueurs vivent dans le `.meta()` **natif** de Zod. Le cœur ne fournit aucun helper : il augmente
le registre global de Zod (`GlobalMeta`, `configMeta.ts#zod`), si bien que `.meta({ secret: true })`
est typé partout — dans le cœur, dans les modules **et** dans les applications. `z.toJSONSchema()`
les recopie tels quels, ce qui les rend lisibles par Studio sans code d'interface écrit à la main.

> [!WARNING]
> `.meta()` doit être **le dernier maillon de la chaîne**. Chaque méthode Zod retourne une nouvelle
> instance, et la métadonnée est attachée à l'instance : écrire `.meta({…}).default(x)` la perd
> **silencieusement** (`configMeta.ts#IConfigFieldMeta`). L'ordre correct est `.default(x).meta({…})`.

### La réactivité déclarée

Un second axe, cousin mais distinct, classe les champs entre **applicables à chaud** et **figés au
boot** : `configReactivity` (`reactivity.ts#configReactivity`). Le défaut est volontairement conservateur — un champ
non listé vaut `boot` (`getConfigReactivity()`, `reactivity.ts#getConfigReactivity`), parce qu'on ne prétend jamais
« à chaud » par erreur. Aujourd'hui, seuls les réglages de journalisation y figurent : c'est
exactement le cadre d'une fenêtre d'observation ouverte temporairement en production.

## 🏗️ Architecture interne — le parcours d'une valeur au boot

```mermaid
sequenceDiagram
  participant B as bin/nodefony
  participant K as Kernel.loadApp
  participant D as Descripteur defineConfig
  participant Z as Zod appConfigSchema
  participant M as Modules

  B->>B: loadEnv() — le .env du poste
  B->>K: boot
  K->>K: import de l'app → export `env` (catalogue) + export default
  K->>K: buildConfigContext(env) → ctx {env, infra, isProd…}
  K->>D: resolve(ctx)
  D->>D: input(ctx) puis deep-merge SOUS defaultAppConfig
  D->>D: applyAppEnvOverrides — NF__APP__…
  D->>Z: validateAppConfig
  Z-->>D: OK (sinon : erreur agrégée, chemin fautif)
  D-->>K: config résolue + rapport d'overrides
  K->>M: loadModulesFromManifest — import ordonné + config `use()`
  K->>M: overrides `module-<nom>` puis NF__MODULE__…
  M->>M: onKernelRegister — chaque module valide SON schéma Zod
```

Les points de passage, dans l'ordre du code :

1. **`loadEnv()`** (`loadEnv.ts#loadEnv`) peuple `process.env` avant tout Kernel — les configs de modules
   lisent l'environnement au boot, il doit donc déjà être là.
2. **`Kernel.buildConfigContext()`** (`Kernel.ts#Kernel.buildConfigContext`) fabrique `ctx`. Le catalogue `env` exporté par
   l'app y est branché (`Kernel.ts#Kernel.loadApp`) ; sans catalogue, `ctx.env` retombe sur `process.env` brut.
3. **`descriptor.resolve(ctx)`** (`Kernel.ts#Kernel.resolveAppOptions`) enchaîne merge, overrides `NF__APP__*` et
   validation — les trois dans `mergeAndValidate()` (`defineConfig.ts#mergeAndValidate`).
4. **Le rapport d'overrides est différé.** Le merge tourne **avant** que le logger existe : le rapport
   est rangé sur la config en clé non énumérable (`readAppEnvOverrideReport()`, `defineConfig.ts#readAppEnvOverrideReport`)
   puis émis quand le logger est prêt (`Kernel.surfaceAppEnvOverrides()`, `Kernel.ts#Kernel.surfaceAppEnvOverrides`).
5. **Les modules suivent la même mécanique, un cran plus tard** : chargement dans l'ordre du manifeste
   et deep-merge de la config `use()` sur leurs défauts (`Kernel.loadModulesFromManifest()`,
   `Kernel.ts#Kernel.loadModulesFromManifest`), puis overrides inter-modules `module-<nom>`
   (`Module.readOverrideModuleConfig()`, `Module.ts#Module.readOverrideModuleConfig`) et d'environnement
   (`Kernel.applyEnvConfigOverrides()`, `Kernel.ts#Kernel.applyEnvConfigOverrides`).
6. **Ces overrides tombent entre l'enregistrement et la validation** (`Kernel.ts#Kernel.preRegister`) — et l'ordre
   n'est pas anodin : posés plus tard, ils seraient silencieusement ignorés par tout module qui fige
   sa config tôt.

### L'infra déclarée — un signal, pas une devinette

`resolveInfra()` (`infra.ts#resolveInfra`) lit les URLs déclarées (base, cache, journalisation) et les expose
en `ctx.infra`. Les briques dont le store vaut la sentinelle `"auto"` (`AUTO_STORE`, `infra.ts#AUTO_STORE`)
s'y branchent via `resolveAutoStore()` (`infra.ts#resolveAutoStore`).

La doctrine est explicite : `auto` ne choisit que parmi les backends **réellement enregistrés**, et
tout repli est **annoncé**, jamais silencieux. Une valeur explicite ne passe jamais par `auto`. La
résolution effective de chaque brique est enregistrée au boot (`Kernel.registerStoreResolution()`,
`Kernel.ts#Kernel.registerStoreResolution`) — donc consultable après coup, plutôt que devinée.

### Quand la config est invalide — le boot s'arrête proprement

Une config cassée n'est pas récupérable : le framework ne peut pas deviner tes ports ni tes modules.
`Kernel.bootConfigError()` (`Kernel.ts#Kernel.bootConfigError`) en fait un échec **soigné** plutôt qu'une trace brute :

- un diagnostic lisible : titre, cause, champ Zod nommé, **et les valeurs par défaut du framework**
  explicitées (`Kernel.formatDefaults()`, `Kernel.ts#Kernel.formatDefaults`) ;
- pas de pile d'appels — c'est une faute de configuration, pas un bogue du framework ;
- un **code de sortie dédié** — `err.exitCode = SysExit.CONFIG`, soit `EX_CONFIG` (78)
  (`Kernel.ts#Kernel.bootConfigError`) — pour qu'un orchestrateur
  distingue « mauvaise configuration » d'un plantage logiciel et ne relance pas en boucle.

Le message reste précis même dans les unions : `flattenZodIssues()` (`schema.ts#flattenZodIssues`) descend dans les
branches pour éviter le très inutile « `servers.https`: Invalid input » et rendre
« `servers.http.port`: Expected number, received string ».

## Le cas à part — la topologie cluster

`nodefony/config/cluster/cluster.config.ts` **ne rejoint pas** `nodefony.config.ts`, et c'est
délibéré. Le processus maître doit décider **combien de processus forker** avant qu'aucun Kernel
n'existe : il importe donc ce fichier **seul**, compilé, sans rien booter (`loadClusterConfig()`,
`topology.ts#loadClusterConfig`).

Conséquence directe : ce fichier doit rester **kernel-free**. Aucun `Nodefony.getKernel()`, aucun
import qui en déréférence un — sinon l'import échoue, et la lecture retombe silencieusement sur le
défaut. La résolution finale suit une précédence propre (`resolveTopology()`, `topology.ts#resolveTopology`) :

1. le drapeau CLI `nodefony cluster --workers <n|auto>` ;
2. la variable `NF_WORKERS` (Docker, Kubernetes) ;
3. ce fichier (`cluster.workers`) — le défaut choisi par l'exploitation.

## 📡 Observabilité — Studio

La configuration **résolue** — celle qui tourne réellement, pas le fichier source — est exposée à la
console d'administration.

- **Page globale** `/nodefony/config` (`ConfigPage.tsx`) : la config agrégée de tous les modules,
  valeurs effectives **rédigées**, JSON Schema et provenance par champ. Data plane :
  `GET /nodefony/kernel/api/config` (`KernelAdminApi.ts#createKernelAdminApi`).
- **Provenance** : chaque valeur porte son origine — défaut, projet, ou environnement
  (`ConfigOrigin`, `configProvenance.ts#ConfigOrigin`), calculée par `computeConfigProvenance()`
  (`configProvenance.ts#computeConfigProvenance`). C'est ce qui répond en dix secondes au « pourtant j'ai bien mis la
  valeur ».
- **Édition à chaud, bornée** : `PATCH /nodefony/kernel/api/config/{module}`
  (`KernelAdminApi.ts#createKernelAdminApi`) modifie **un** champ. La surface est fermée à chaque étape — réservée au
  développement, module doté d'un schéma requis, champ `runtimeMutable` **seul**, valeur validée
  contre le JSON Schema, application en mémoire (donc éphémère), et journalisation d'audit. L'ordre
  des refus est explicite : secret, puis réservé, puis dérivé du kernel, puis « boot seulement »
  (`notEditableReason()`, `configMutation.ts#notEditableReason`).

Pour tout le reste, l'écran propose la **recette d'override** correspondante — un secret par la
variante `*_FILE`, un champ figé par `NF__…` au redéploiement. La console n'invente jamais un chemin
d'édition qui contournerait le modèle 12-factor.

## ⚡ Performance & mémoire

La configuration est **entièrement résolue au boot**, puis figée. Il n'en reste rien sur le chemin
d'une requête :

- **zéro lecture de `process.env` en production de trafic** — le catalogue est lu une fois et gelé
  (`defineEnv.ts#withDoc`) ;
- **zéro deep-merge par requête** — `extend(true, …)` ne tourne qu'au `resolve()` (`defineConfig.ts#BrandedDescriptor.resolve`),
  sur une cible fraîche, sans muter ni les défauts ni l'entrée ;
- **zéro analyse d'override** au-delà du boot : `parseNfEnvOverrides()` (`envOverride.ts#parseNfEnvOverrides`) et
  `resolveInfra()` (`infra.ts#resolveInfra`) sont appelés une seule fois, l'infra étant mémoïsée
  (`Kernel.infra`, `Kernel.ts#Kernel.infra`) ;
- **zéro module inutile** : une entrée écartée par `policy`/`when` n'est **pas importée**, donc son
  code n'occupe ni le temps de boot ni la mémoire.

Le coût total se paie une fois, sur un chemin froid : rien à optimiser côté requête. C'est
précisément l'objectif du modèle « résoudre puis figer ».

## ⚠️ Pièges (symptôme → cause → correction)

| Symptôme                                               | Cause (dans le code)                                                 | Correction                                                                                   |
| ------------------------------------------------------ | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Crash à l'import : propriété lue sur `null`            | Déréférencement du kernel au top-level d'un fichier de config        | Passer en getter, ou utiliser `ctx` (`Kernel.ts#Kernel.loadApp`)                             |
| `NF__APP__X=…` sans effet, avec « vouliez-vous dire »  | Le chemin n'existe pas dans les défauts (`applyResolvedPath` refuse) | Déclarer la clé dans `nodefony.config.ts` (`envOverride.ts#applyResolvedPath`)               |
| Le champ ciblé refuse la valeur d'un `envEnum`         | `as const` oublié → l'union littérale est élargie en `string`        | `envEnum([...] as const, …)`                                                                 |
| `NF__…__ENABLED=false` interprété comme vrai           | Attendu d'une coercion naïve — ce n'est pas le cas ici               | Rien à faire : `coerceEnvValue()` est explicite (`envOverride.ts#coerceEnvValue`)            |
| Boot rejeté : « Configuration d'application invalide » | Une valeur hors schéma (`validateAppConfig`)                         | Lire le chemin + la raison, corriger (`schema.ts#validateAppConfig`)                         |
| Diagnostic vague sur `servers.https`                   | Union Zod — la branche fautive est masquée                           | Le message descend déjà dans les unions (`schema.ts#serversSchema`)                          |
| `KEY` et `KEY_FILE` définis en même temps              | Ambiguïté de secret, refusée (`resolveFileEnv`)                      | N'en garder qu'un (`defineEnv.ts#resolveFileEnv`)                                            |
| Une métadonnée `.meta()` disparaît                     | `.meta()` n'est pas en dernier — le clone Zod la perd                | `.default(x).meta({…})` (`configMeta.ts#IConfigFieldMeta`)                                   |
| Un override `module-<nom>` semble ignoré               | Il était appliqué après la validation du module                      | Corrigé : appliqué avant (`Kernel.ts#Kernel.preRegister`) — vérifier l'orthographe du module |
| Le cluster ignore `cluster.config.ts`                  | Le fichier déréférence le kernel → import KO → repli silencieux      | Le garder **kernel-free** (`topology.ts#loadClusterConfig`)                                  |
| `.env.example` désynchronisé                           | `env.ts` modifié sans régénération                                   | Le régénérer depuis le catalogue (`envExample.ts#renderEnvExample`)                          |
| Une modification de config n'a aucun effet en dev      | Le boot lit le **dist**, pas la source                               | Rebuilder la racine avant de relancer (cf le [guide](../../guides/configuration/))            |

## 🧪 Tests & couverture

Quatre familles couvrent le moteur — les compteurs exacts vivent dans la carte de l'aperçu, régénérée
depuis vitest, jamais figés ici :

- **Le moteur de résolution** — `defineConfig.test.ts` (formes objet et fonction, deep-merge sous les
  défauts, invariants d'immuabilité), `configBoot.test.ts` (le boot réel : diagnostic, code de
  sortie, fail-fast) et `configUse.test.ts` (manifeste, `policy`/`when`, typage par le registre).
- **L'environnement** — `defineEnv.test.ts` (coercions, requis/optionnel, `*_FILE`, gel de l'objet),
  `loadEnv.test.ts` (`.env` seul, refus de l'ancienne convention) et `envExample.test.ts` (l'anti-dérive du fichier d'exemple).
- **Les gardes d'override** — `envOverride.test.ts`, la suite la plus fournie : coercions explicites,
  casse, refus des chemins inconnus, suggestions « vouliez-vous dire », rédaction des secrets.
- **La configuration côté modules** — `configProvenance.test.ts` (l'origine par champ), les bancs
  d'édition à chaud de `@nodefony/framework` (`configMutation.test.ts`,
  `configMutationEndpoint.test.ts`) et les builders des modules (`httpConfig.test.ts`,
  `defineSecurityConfig.test.ts`, `defineRealtimeConfig.test.ts`).

Ce qui **n'existe pas**, et n'est pas nécessaire : aucun test de charge dédié à la configuration — elle
est intégralement résolue sur un chemin froid, et n'apparaît dans aucun profil de requête.

Couverture : `npm run coverage` dans `src/nodefony`.

## 🔗 Pour aller plus loin

- ⬆️ **Retour** : [Toute la documentation](../../)
- 🧭 **Pages sœurs** : [Cycle de boot du Kernel](../cycle-boot-kernel/) · [Injection et portées](../injection-portees/) · [Vue d'ensemble](../vue-ensemble/)

- Le **pas-à-pas** (recettes, Docker, héritage quand Nodefony est une dépendance) →
  [guide de configuration](../../guides/configuration/)
- La décision d'architecture qui fixe l'échelle de précédence →
  [ADR-0006](https://github.com/nodefony/nodefony-core/blob/main/docs/adr/0006-configuration-unifiee-env-override.md)
- Quand exactement la config est résolue dans la séquence de démarrage →
  [cycle-boot-kernel](../cycle-boot-kernel/)
- Où la config atterrit sur le chemin d'une requête → [pipeline-requete](../pipeline-requete/)
