Registre runtime en pydantic-settings : domaines indépendants, validation par point d'entrée
Contexte
ADR-0024 nomme le registre runtime et lui assigne un
module canonique (electricore/config/runtime.py, issue #141). L'état de départ : trois
loaders de .env (parseur artisanal de config/env.py, préambule de pipeline_dbt.py,
convention dlt), des îlots de lecture par module (APISettings recopiant 11 os.getenv
à la main dans un BaseModel pydantic, charger_config_odoo, lectures directes dans
crypto.py et la source SFTP), aucun fail-fast — une var manquante explose au premier
usage, parfois en plein run. Le bug « pipeline_dbt ignorait DUCKDB_PATH en Docker »
venait de cette duplication.
ADR-0018 fait des @dataclass(frozen=True) la
forme par défaut, mais exempte explicitement les BaseModel pydantic (« leur propre
machinerie de déclaration de schéma »). La revue d'architecture du 12/06/2026 a comparé
les deux voies (pydantic-settings vs dataclasses artisanales) sur l'état de l'art
(Prefect 3, écosystème FastAPI).
Décision
Domaines indépendants, pas de racine
runtime.py définit un BaseSettings indépendant par domaine — sftp, aes,
duckdb, api, bot, odoo — chacun mappé sur les noms de vars existants (aucun
renommage : SFTP__URL, AES__CURRENT__KEY, DUCKDB_PATH, TELEGRAM_*, ODOO_*),
avec .env ancré sur la racine du dépôt et la précédence env-système > .env (native
pydantic-settings). Pas d'objet racine : un domaine non demandé n'est jamais validé.
Accès par accessors de module mis en cache : runtime.sftp(), runtime.bot()…
Cas particulier : runtime.odoo(env=None) résout le sélecteur ODOO_ENV (test/prod)
et injecte le préfixe ODOO_{ENV}_ à l'instanciation — seul l'environnement demandé est
validé.
Mise à jour (#439, ADR-0046 §5) — le sélecteur a été retiré :
runtime.odoo()lit désormais un bloc unique read-onlyODOO__{URL,DB,USERNAME,PASSWORD}. Le reste de cette ADR décrit l'état d'origine et est conservé tel quel.
Fail-fast par point d'entrée
Un helper valider(*accessors) appelle chaque domaine, collecte les ValidationError,
et lève une unique ConfigurationManquante listant toutes les vars manquantes,
groupées par domaine. Chaque point d'entrée déclare ses domaines au boot :
| point d'entrée | domaines validés |
|---|---|
| API (lifespan) | duckdb, api |
| bot | bot (+ odoo si l'instance a un ERP — no-ERP servi, ADR-0022) |
| runner ETL | sftp, aes, duckdb |
| notebooks | rien d'imposé — chaque accessor valide à son premier appel |
Conséquences structurantes sur l'existant
API_BASE_URLest supprimée. Le bot tourne toujours dans le processus de l'API (aucun service bot dans le compose) ; son client httpx reçoithttpx.ASGITransport(app=app)via le paramètretransportexistant (#174) — mêmes endpoints, même authX-API-Key, zéro socket. Le domaine bot se réduit àTELEGRAM_*.- Une seule façade conservée :
charger_config_odoo()(2 lignes déléguant àruntime.odoo(), préserve 8 notebooks et l'API documentée).charger_env()est supprimée — vérification faite, la machinerie dlt ne résout aucun de nos secrets (destination passée en Python, source sans injectiondlt.secrets.value) : les lecturesdlt.secretsétaient toutes dans notre propre code. Plus rien n'exige qu'os.environsoit peuplé depuis.env;config/env.pydisparaît entièrement. - Migration + suppression :
chemin_base_duckdb()→runtime.duckdb().chemin(tous les callers sont in-repo). - L'ETL consomme runtime, exclusivement :
crypto.pyconstruit sa chaîne de clés depuisruntime.aes()(modèles imbriqués,AES__CURRENT__KEYse parse nativement avec le délimiteur__), la source SFTP etdiagnostic_fluxlisentruntime.sftp(). Les trois lecturesdlt.secretsdisparaissent et le support du fichier.dlt/secrets.tomlest retiré (aucun environnement connu ne l'utilise — pas même le poste de dev) : qui l'utilisait migre ses valeurs vers.env, une fois. La cascade de rotation AES d'ADR-0008 (current→previous→ legacy) est conservée à l'identique sur le format envAES__CURRENT__*/AES__PREVIOUS__*.
Raison
- Le contrat par point d'entrée tombe naturellement des domaines indépendants.
Une racine unique aurait imposé
Optionalsur tous les domaines (pour ne pas casser notebooks et instance no-ERP), transformant chaque accès en None-check — le typage qu'on venait chercher s'évaporait. - La convention
__est déjà celle du dépôt.SFTP__URL,AES__CURRENT__KEYsuivent la convention dltSECTION__CLÉ, qui est exactement leenv_nested_delimiter="__"de pydantic-settings : aucun renommage, parsing natif. - pydantic est déjà dans l'arbre (FastAPI, dlt) ; seule
pydantic-settingss'ajoute. La validation déclarative et l'agrégation d'erreurs sont gratuites — la version dataclasses re-codait ~150 lignes de coercition, parsing.envet collecte de manquantes, en gardant le parseur artisanal. - Le fail-fast tue un footgun réel : aujourd'hui, oublier
ODOO_ENV=proden prod fait défaut silencieusement sur la base test. Avec les domaines, ce cas chercheODOO_TEST_*(non définies en prod) et lèveConfigurationManquanteau boot.
Alternatives écartées
- Dataclasses frozen artisanales (
depuis_env()) — zéro dépendance et forme par défaut d'ADR-0018, mais ~150 lignes sur mesure pour un confort moindre, et ADR-0018 exempte précisément lesBaseModelde sa convention. Restera la voie de repli si pydantic-settings devait sortir de l'arbre. - Racine unique nested (
RuntimeSettings+ sous-modèles) — un seul objet mais validation monolithique : incompatible avec no-ERP et notebooks sansOptionalgénéralisé. - Conserver
API_BASE_URL— une var pour un réseau qui n'existe pas ; le seam transport (#174) permet de la faire renaître le jour où le bot devient un conteneur séparé (deux formes de déploiement = la var serait alors justifiée).
Conséquences
api/config.py(APISettings) est absorbé : champs API → domaineapi(titre, version, description,INSTANCE_SLUG, clés),TELEGRAM_*→ domainebot,ODOO_ENV→ accessorodoo; la validation de clé (secrets.compare_digest) suit le domaineapi;public_endpoints(constante, pas de la config env) part enapi/security.py..env.example,deploy/docker/.env.example(suppression d'API_BASE_URL) et docs/configuration.md sont mis à jour — ce dernier restructuré par registres d'ADR-0024 (sa section 1 devient la doc deruntime.py).- La procédure de rotation AES (CLAUDE.md, ADR-0008) est
re-documentée env-d'abord : la décision de fond d'ADR-0008 — la cascade à deux clés —
est inchangée, seul son véhicule
secrets.tomldisparaît. - Tests : chaque domaine s'instancie avec des valeurs d'init explicites, sans
monkeypatch d'
os.environ;ConfigurationManquantese teste par domaine. - L'entrée « Config partagée » de core/CONTEXT.md est révisée : « stdlib-only » devient « aucune dépendance ERP ; pydantic-settings est la seule lib externe admise ici ».
Limites à connaître
DBT_DUCKDB_PATHreste le seul pontos.environ: dbt est invoqué in-process (dbtRunner) etenv_var()est l'unique mécanisme de paramétrage deprofiles.yml— dbt n'a pas d'API destination comme dlt. La var est posée par le runner immédiatement avant l'invocation (scopéetry/finallypour ne pas survivre dans un process API), porte la même valeur que la destination dlt (runtime.duckdb().chemin, résolu une fois, deux transports) et n'est jamais lue depuis.env. L'alternative — générer unprofiles.ymltemporaire par run — troquerait une ligne documentée contre de la génération de fichiers.- ~~La mécanique
ODOO_ENV/ODOO_TEST_*est conservée à l'identique dans l'attente de #190 (stratégie de test des écritures Odoo).~~ Levé (#439) : #190 est clos completed (plus d'écritures Odoo à répéter) — le sélecteur a été remplacé par le bloc unique read-onlyODOO__*(ADR-0046 §5). Odoo reste read-only (ADR-0012). - Si le bot devient un conteneur séparé, le transport ASGI ne suffit plus : injecter
un transport réseau et réintroduire une URL de base — le paramètre
transportdu client est le seam prévu pour ça.