Aller au contenu

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.uuid est de type uuid6.UUID, pas uuid.UUID stdlib.
  • Les adaptateurs MongoDB stockent l'UUID en string pour compatibilité.
  • @field_validator("uuid", mode="before") sur Entity coerce automatiquement les strings en UUID.

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 étend BaseModel, pas dataclass.
  • model_config = ConfigDict(arbitrary_types_allowed=True) pour accepter uuid6.UUID.
  • Les @field_validator utilisent mode="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.
  • PurgeUseCase supprime physiquement celles dont deleted_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émenter version avant 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.path valide l'extension : seuls .csv, .parquet, .json, .arrow sont 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.py ne doit plus être créé dans les repos consommateurs.
  • Seuls run_mcp_sse() et run_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.pyrun_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.py et fastmcp/dependencies.py : wrappers ~10 lignes.
  • fastapi/auth.py et fastmcp/auth.py : protection sélective opt-in (par route ou par tool).
  • Arclith.auth_dependency(transport) : factory qui construit le bon require_auth depuis 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/inbound pour les capacités exposées par le coeur;
  • domain/ports/outbound pour les dépendances appelées par le coeur;
  • adapters/inbound pour HTTP, MCP, CLI, workers, agents;
  • adapters/outbound pour 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.repository accepte un nom libre.
  • build_repository(..., registry=...) peut recevoir un registry applicatif.
  • Arclith.repository(..., registry=...) expose ce point d'extension.
  • ProjectLayout expose 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 :

  • CapabilitySpec supporte les adapters non scopes par entite et les templates .env.
  • arclith-cli add-adapter --capability agent --adapter langgraph genere l'entrypoint agent inbound et laisse le code specifique dans src/<package>/adapters/inbound/langgraph/agent.py.
  • config/adapters/inbound/langgraph.yaml est charge dans AppConfig.langgraph, sur le meme principe produit que fastapi et fastmcp.
  • arclith-cli add-adapter --capability observability --adapter langsmith demande les parametres LangSmith et n'essaie pas de generer un repository par entite.
  • AdaptersSettings.observability.enabled active 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 .env via le langgraph.json genere.

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 :

  • CommandDispatcher mappe CommandEnvelope.command_type vers un CommandHandler projet.
  • Les handlers transforment le payload en DTO/command applicative, puis appellent un use case ou port inbound.
  • RabbitMQCommandBus utilise 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 init génère Dockerfile, .dockerignore et arclith-run.
  • arclith-cli add-adapter --capability runtime --adapter docker-image régénère ces fichiers dans un projet existant.
  • Les secrets ne sont jamais fournis par ARG/ENV de build; ils restent dans l'environnement runtime, Docker secrets, Vault ou fichiers montés.
  • Le mode agent reste configurable avec ARCLITH_AGENT_COMMAND pour 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 arclith n'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.example contient uniquement des réglages non secrets et .env reste 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_KEY ou LANGSMITH_API_KEY n'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 un FastMCP instance.
  • Les tools sont enregistrés via @mcp.tool (décorateur) à l'intérieur des classes *MCP.
  • run_mcp_sse() et run_mcp_http() lisent config.mcp.host / config.mcp.port.