docs/decisions.md — Arclith (arclith)¶
ADR-001 — UUIDv7 comme identifiant d'entité¶
Contexte : Choix de l'algorithme d'ID pour les entités.
Décision : UUIDv7 via la bibliothèque uuid6.
Pourquoi pas l'alternative évidente (UUIDv4) : UUIDv4 est aléatoire — pas d'ordre temporel, ce qui dégrade les index B-tree (MongoDB, DuckDB) et rend le tri par ID impossible. UUIDv7 est ordonné par le temps à la milliseconde, combine les avantages d'un ULID et d'un UUID standard.
Conséquence sur le code :
Entity.uuidest de typeuuid6.UUID, pasuuid.UUIDstdlib.- Les adaptateurs MongoDB stockent l'UUID en string pour compatibilité.
@field_validator("uuid", mode="before")surEntitycoerce automatiquement les strings enUUID.
ADR-002 — Pydantic v2 comme système de modèles¶
Contexte : Validation et sérialisation des entités et de la config.
Décision : Pydantic v2 (pydantic==2.x), BaseModel avec ConfigDict.
Pourquoi pas l'alternative évidente (dataclasses stdlib) :
Les dataclasses n'ont pas de validation automatique des types à l'instanciation, pas de sérialisation JSON intégrée, pas
de field_validator. Pydantic v2 offre la validation au runtime et est le standard de l'écosystème FastAPI.
Conséquence sur le code :
EntityétendBaseModel, pasdataclass.model_config = ConfigDict(arbitrary_types_allowed=True)pour accepteruuid6.UUID.- Les
@field_validatorutilisentmode="before"pour la coercion.
ADR-003 — Soft-delete par champ deleted_at¶
Contexte : Suppression logique des entités sans perte de données.
Décision : Champ deleted_at: datetime | None sur Entity. Suppression physique différée via PurgeUseCase selon
retention_days.
Pourquoi pas l'alternative évidente (champ booléen is_deleted) :
Un booléen ne porte pas d'information temporelle. deleted_at permet de calculer le délai de rétention sans champ
supplémentaire et de trier les suppressions par date.
Conséquence sur le code :
find_all()filtre automatiquement les entités oùdeleted_at is not None.find_deleted()retourne uniquement les supprimées.PurgeUseCasesupprime physiquement celles dontdeleted_at + retention_days < now.retention_days: null= conservation infinie ;0= suppression immédiate (pas de soft-delete).
ADR-004 — Optimistic locking via champ version¶
Contexte : Prévenir les écritures concurrentes conflictuelles.
Décision : Champ version: int = 1 incrémenté à chaque update.
Pourquoi pas l'alternative évidente (pessimistic locking / transactions) : Les transactions MongoDB ont un surcoût significatif. L'optimistic locking est suffisant pour les volumes cibles et évite les deadlocks dans un contexte async.
Conséquence sur le code :
- Les adaptateurs
update()doivent incrémenterversionavant persistance. - Les clients qui soumettent une version obsolète recevront un conflit (à implémenter dans les use cases si nécessaire).
ADR-005 — DuckDB comme adaptateur fichier plutôt que SQLite¶
Contexte : Persistance légère sans serveur pour le dev et les sandboxes.
Décision : DuckDB via duckdb==1.5.0, formats supportés : .csv, .parquet, .json, .arrow.
Pourquoi pas l'alternative évidente (SQLite) : DuckDB est orienté analytique et supporte nativement Parquet, Arrow et CSV sans ORM. Il est plus adapté à des exports de données et à la lecture de fichiers plats. SQLite nécessiterait un ORM ou du SQL manuel.
Conséquence sur le code :
DuckDBSettings.pathvalide l'extension : seuls.csv,.parquet,.json,.arrowsont acceptés (ou un dossier).DuckDBRepository[T]reconstruit les entités depuis les colonnes du fichier.- Pas de migrations : le schéma est inféré depuis le modèle Pydantic.
ADR-007 — Suppression du transport MCP stdio¶
Date : 2026-03-28
Contexte : arclith exposait trois transports MCP : stdio, SSE et streamable-HTTP. Le transport stdio est
fondamentalement incompatible avec un déploiement Kubernetes (il repose sur stdin/stdout d'un subprocess local) et ne
supporte pas les headers HTTP, rendant toute authentification JWT impossible.
Décision : supprimer run_mcp_stdio() de Arclith.
Pourquoi pas l'alternative (garder stdio pour usage local) : Garder du code mort augmente la surface de test et introduit une confusion : les développeurs pourraient croire que le transport stdio est supporté en production. Le debug local passe par HTTP (127.0.0.1) avec les mêmes outils.
Conséquence sur le code :
Arclith.run_mcp_stdio()supprimé.main_mcp_stdio.pyne doit plus être créé dans les repos consommateurs.- Seuls
run_mcp_sse()etrun_mcp_http()sont conservés.
ADR-008 — Pipeline d'authentification JWT mutualisé FastAPI / FastMCP¶
Date : 2026-03-28
Contexte : FastAPI et FastMCP ont fondamentalement le même besoin : extraire un Bearer token, le valider via
Keycloak JWKS, vérifier la licence, résoudre le tenant. La seule différence est la façon d'accéder aux headers HTTP
(Request vs fastmcp.Context).
Décision : extraire le cœur du pipeline dans adapters/inbound/auth_pipeline.py → run_auth_pipeline(headers, ...).
Les adapters FastAPI et FastMCP sont de simples wrappers qui extraient les headers selon leur transport puis appellent
run_auth_pipeline. Signatures identiques pour make_inject_tenant_uri.
Pourquoi pas l'alternative (deux implémentations séparées) : La logique dupliquée crée une dérive inévitable. Un bugfix ou une évolution (nouveau claim, nouveau type de resolver) devrait être appliqué deux fois.
Conséquence sur le code :
auth_pipeline.py: unique source de vérité pour la logique JWT.fastapi/dependencies.pyetfastmcp/dependencies.py: wrappers ~10 lignes.fastapi/auth.pyetfastmcp/auth.py: protection sélective opt-in (par route ou par tool).Arclith.auth_dependency(transport): factory qui construit le bonrequire_authdepuis la config.- Tests du pipeline mutualisé : un seul fichier
tests/units/adapters/inbound/test_auth_pipeline.py(à créer — SK-AUTH-01).
ADR-009 — ws="websockets-sansio" imposé sur toutes les configs uvicorn¶
Date : 2026-04-04
Contexte : uvicorn 0.41.0 sélectionne automatiquement websockets_impl.py (legacy) quand websockets est installé
(via fastmcp). Ce module importe websockets.legacy, déprécié depuis websockets 14.0. fastmcp/__init__ active
globalement warnings.simplefilter("default", DeprecationWarning), rendant le warning visible à chaque démarrage.
Décision : Passer ws="websockets-sansio" explicitement à chaque construction de uvicorn.Config / uvicorn.run()
dans arclith. L'implémentation websockets_sansio_impl.py n'utilise que la nouvelle API websockets (≥14.0), sans
import legacy.
Pourquoi pas l'alternative évidente (supprimer le warning via filterwarnings) :
Masquer un avertissement sans corriger la cause racine est interdit : cela cache une dette technique et peut dissimuler
des régressions futures. Règle absolue : on ne masque jamais un warning sans corriger sa source.
Pourquoi pas bump de uvicorn :
Avant tout bump de dépendance, il faut vérifier quelle version exacte corrige le comportement ciblé (règle SK-F10).
Ici, ws="websockets-sansio" est disponible depuis uvicorn 0.20+, la correction est déterministe et ne nécessite
aucun changement de contrainte dans pyproject.toml.
Conséquence sur le code :
Arclith.run_api()—uvicorn.run(..., ws="websockets-sansio")ProbeServer.start_in_background()—uvicorn.Config(..., ws="websockets-sansio")websockets_impl.py(legacy) n'est jamais chargé par arclith.
ADR-010 — Layout hexagonal inbound/outbound et adapters enregistrables¶
Date : 2026-08-03
Contexte : Arclith doit devenir une base générique pour des microservices et agents qui pourront utiliser FastAPI, FastMCP, LangGraph, Pydantic AI ou d'autres frameworks. Le coeur applicatif ne doit jamais dépendre de ces adapters. Côté persistence, MongoDB est déjà disponible, mais d'autres adapters comme MariaDB, PostgreSQL ou des event stores doivent pouvoir être ajoutés sans modifier le framework.
Décision : formaliser le vocabulaire hexagonal cible:
domain/ports/inboundpour les capacités exposées par le coeur;domain/ports/outboundpour les dépendances appelées par le coeur;adapters/inboundpour HTTP, MCP, CLI, workers, agents;adapters/outboundpour repositories, LLM, cache, secrets, events.
Les anciens noms input et output ne sont pas conservés: Arclith est encore en phase
initiale et cette refonte choisit une rupture nette plutôt qu'une compatibilité temporaire.
Les adapters de repositories ne sont plus sélectionnés par un match central fermé: ils passent par un
RepositoryRegistry avec adapters built-in enregistrés par défaut.
Pourquoi pas l'alternative évidente (ajouter MariaDB dans le switch existant) : Un switch central oblige Arclith à connaître chaque adapter concret. Cela casse l'extension naturelle du framework, mélange le coeur d'assemblage et les drivers, et recrée de la dette à chaque nouvelle technologie.
Conséquence sur le code :
AdaptersSettings.repositoryaccepte un nom libre.build_repository(..., registry=...)peut recevoir un registry applicatif.Arclith.repository(..., registry=...)expose ce point d'extension.ProjectLayoutexpose uniquement les chemins canoniques inbound/outbound.- Les futurs adapters MariaDB/PostgreSQL peuvent être livrés comme packages ou modules séparés.
ADR-011 — LangSmith Studio comme banc de test agent¶
Date : 2026-08-05
Contexte : Arclith doit servir de base commune pour des services exposes par API, MCP ou agents. Le coeur applicatif enregistre des donnees deterministes via des cas d'usage; l'agent traduit une intention en commande structuree a la frontiere. Le POC todo a montre qu'une UI dediee n'est pas necessaire si LangGraph Studio et LangSmith couvrent les tests conversationnels et les traces.
Decision : declarer le runtime agent comme une capacite inbound agent, avec un adapter
standard langgraph, et declarer l'observabilite agent comme une capacite outbound
observability, avec les adapters standards langsmith et opentelemetry activables en
parallele via observability.enabled. La CLI genere le point d'entree LangGraph,
langgraph.json et le cablage Arclith.langgraph(...). La configuration runtime reste nommee par
produit, comme fastapi et fastmcp: config/adapters/inbound/langgraph.yaml alimente
AppConfig.langgraph, sans cle generique adapters.agent. La CLI demande ensuite les informations
LangSmith au moment du add-adapter, genere config/adapters/outbound/langsmith.yaml, met a jour
.env, et ignore .env dans Git. OpenTelemetry peut etre ajoute ensuite sans remplacer LangSmith.
LangSmith Studio devient l'endroit standard pour tester les agents.
Pourquoi pas l'alternative evidente (une UI de test generee par Arclith) : Une UI de test serait une surface produit supplementaire a maintenir, sans porter le metier. Elle dupliquerait les fonctions de LangSmith Studio: execution locale, conversation, traces, inspection et debug agent. Arclith doit plutot standardiser le branchement et laisser l'outil specialise porter les tests agent.
Consequence sur le code :
CapabilitySpecsupporte les adapters non scopes par entite et les templates.env.arclith-cli add-adapter --capability agent --adapter langgraphgenere l'entrypoint agent inbound et laisse le code specifique danssrc/<package>/adapters/inbound/langgraph/agent.py.config/adapters/inbound/langgraph.yamlest charge dansAppConfig.langgraph, sur le meme principe produit quefastapietfastmcp.arclith-cli add-adapter --capability observability --adapter langsmithdemande les parametres LangSmith et n'essaie pas de generer un repository par entite.AdaptersSettings.observability.enabledactive une ou plusieurs sorties d'observabilite sans coupler le coeur aux SDK agent.Arclith.langgraph(...)standardise la creation et la compilation du graphe sans exposer cette plomberie a chaque projet.- Le serveur LangGraph local lit
.envvia lelanggraph.jsongenere.
ADR-012 — Command Bus RabbitMQ use-case first¶
Date : 2026-08-09
Contexte : Certains services doivent consommer des commandes via RabbitMQ et publier des
commandes vers d'autres workers. Le même adapter technique porte donc une surface inbound
(message -> handler -> use case) et outbound (publisher -> exchange).
Décision : déclarer une capability command-bus avec un layer explicite bidirectional.
RabbitMQ reste un adapter optionnel derrière arclith[rabbitmq]. Le domaine expose seulement les
ports CommandHandler et CommandPublisher; aucune dépendance aio-pika ne traverse vers le coeur
métier. La config runtime est portée par config/command_bus.yaml et AppConfig.command_bus.
Pourquoi pas l'alternative évidente (classer RabbitMQ uniquement inbound ou outbound) :
Un worker RabbitMQ consomme et peut publier des commandes critiques avec le même channel fiable.
Le ranger arbitrairement dans un seul layer masquerait cette réalité et pousserait les projets à
dupliquer le câblage. Le layer bidirectional rend le compromis explicite sans autoriser l'adapter à
contourner les use cases.
Conséquence sur le code :
CommandDispatchermappeCommandEnvelope.command_typevers unCommandHandlerprojet.- Les handlers transforment le payload en DTO/command applicative, puis appellent un use case ou port inbound.
RabbitMQCommandBusutilise ack manuel, publisher confirms, prefetch strictement positif et DLX configurable.Arclith.run_command_bus(dispatcher)fournit un runner bloquant compatible worker Docker.
ADR-013 — Image Docker runtime unique et non-root¶
Date : 2026-08-09
Contexte : Les projets Arclith peuvent exposer les mêmes cas d'usage via API FastAPI, MCP, worker RabbitMQ ou agent LangGraph. Générer une image par transport pousserait à rebuilder pour un simple choix de runtime et augmenterait le risque d'images divergentes.
Décision : déclarer une capability runtime avec l'adapter docker-image. Le Dockerfile généré
est multi-stage, Python 3.13, installe les dépendances avec uv sync --frozen, puis exécute l'image
finale sous l'utilisateur non-root 1001:1001. Le choix du transport passe par l'entrypoint
arclith-run, via un argument, ARCLITH_RUNTIME_MODE ou MODE.
Pourquoi pas l'alternative évidente (un Dockerfile par transport) : API, MCP, bus et agent partagent le même code applicatif et la même config Arclith. Des images séparées multiplieraient les locks, les scans et les chemins de déploiement sans isoler davantage la logique métier. Un entrypoint runtime garde le rebuild réservé aux changements de code ou de dépendances.
Conséquence sur le code :
arclith-cli initgénèreDockerfile,.dockerignoreetarclith-run.arclith-cli add-adapter --capability runtime --adapter docker-imagerégénère ces fichiers dans un projet existant.- Les secrets ne sont jamais fournis par
ARG/ENVde build; ils restent dans l'environnement runtime, Docker secrets, Vault ou fichiers montés. - Le mode
agentreste configurable avecARCLITH_AGENT_COMMANDpour ne pas figer un serveur LangGraph unique.
ADR-014 — Observabilité LangSmith derrière un port neutre¶
Date : 2026-08-26
Contexte : Le scaffold LangSmith d'ADR-011 générait la configuration et .env, mais Arclith ne
consommait pas le YAML au runtime. Chaque projet devait réimplémenter le client, le sampling, la
confidentialité, la propagation et le flush. Importer LangSmith dans les use cases aurait en outre
couplé le coeur à un backend SaaS optionnel.
Décision : le coeur expose seulement TracePort et TraceSpan. Le composition root sélectionne
un NoOpTraceAdapter ou le runtime outbound LangSmith. Le runtime configure le SDK par API, sans
muter l'environnement, et fournit contexte conditionnel, propagation filtrée, cycle de vie et accès
explicite au client natif. Pydantic AI reçoit une capability d'instrumentation par agent; aucune
instrumentation globale n'est appliquée.
Lorsque LangSmith et OpenTelemetry sont actifs ensemble, tracing.mode=otel est obligatoire. Le
provider OpenTelemetry existant reçoit un processor LangSmith; les instrumentations FastAPI et
Pydantic AI ne sont pas créées deux fois.
Conséquences :
pip install arclithn'installe pas LangSmith et le tracer reste no-op;arclith[langsmith]porte le SDK LangSmith et son support OpenTelemetry;- absence de clé ou d'extra avec adapter activé provoque une erreur de démarrage actionnable;
- les pannes d'export restent fail-open;
- le contenu GenAI et les payloads de transport sont absents par défaut;
.env.examplecontient uniquement des réglages non secrets et.envreste ignoré;- les fonctions avancées restent disponibles via
Arclith.langsmith_client()sans être recopiées dans Arclith.
ADR-015 — Runtime LangGraph durable open source sur PostgreSQL et Redis¶
Date : 2026-08-28
Contexte : langgraph dev est volontairement volatile et le serveur standalone officiel de
production requiert une licence LangGraph Cloud. Les projets Jarvis doivent conserver threads,
runs, checkpoints et mémoire après redémarrage, tout en gardant LangSmith optionnel et sans exposer
les agents privés au navigateur.
Décision : fournir un runtime inbound optionnel sous arclith[langgraph-runtime]. Il charge le
contrat langgraph.json, expose le sous-ensemble HTTP/SSE nécessaire au SDK et au Gateway, et attache
les implémentations officielles AsyncPostgresSaver / AsyncPostgresStore. Un catalogue PostgreSQL
séparé conserve les métadonnées de threads/runs. Redis sert uniquement aux verrous distribués et à
l'annulation ; il ne devient pas une source de vérité durable.
Le runtime de développement reste le défaut. Le choix de production est explicite via
ARCLITH_AGENT_RUNTIME=durable, avec DATABASE_URI et REDIS_URI injectées au runtime.
Conséquences :
- aucune clé
LANGGRAPH_CLOUD_LICENSE_KEYouLANGSMITH_API_KEYn'est requise ; - PostgreSQL est sauvegardé/restauré comme donnée critique, Redis est reconstructible ;
- chaque agent reçoit une base ou un schéma et un préfixe Redis isolés ;
- un seul run peut muter un thread à la fois, y compris avec plusieurs replicas ;
- arrêt client, annulation explicite et timeout mettent à jour le statut du run ;
- les erreurs de graphe sont conservées pour l'exploitation mais assainies dans les réponses ;
- la surface ne prétend pas fournir crons, déploiements, webhooks ou plateforme LangSmith.
Contexte : Exposer les services via le Model Context Protocol.
Décision : fastmcp>=3.1.0 avec trois transports : stdio, SSE, streamable-HTTP.
Pourquoi pas l'alternative évidente (implémentation MCP manuelle) : FastMCP gère la sérialisation, le routing et les transports. Une implémentation manuelle serait fragile et ne suivrait pas les évolutions du spec MCP.
Conséquence sur le code :
Arclith.fastmcp(name)retourne unFastMCPinstance.- Les tools sont enregistrés via
@mcp.tool(décorateur) à l'intérieur des classes*MCP. run_mcp_sse()etrun_mcp_http()lisentconfig.mcp.host/config.mcp.port.