Aller au contenu principal

8. Décisions de conception en vigueur

Cette page énonce les décisions structurantes qui tiennent aujourd'hui, et pourquoi. Elle est écrite au présent : elle décrit le système tel qu'il est construit, pas le chemin qui y a mené. Quand une décision a un coût, le coût est nommé.

Socle technique et runtime​

Le runtime est en Rust sur Tokio. Le code qu'un paquet apporte est en Python, chargé dans le processus via un pont PyO3 plutôt que lancé en sous-processus : le pont traduit directement les futures Rust en coroutines Python, donc un appel à ce code ne coûte ni frontière de processus ni aller-retour de sérialisation.

Tout sous-système qui détient un état mutable est un acteur Tokio, avec un canal borné et une poignée clonable. Aucun état n'est partagé entre acteurs derrière un verrou. Le coût est une indirection, et il achète la propriété qui compte à cette taille : un interblocage ne peut pas se former entre deux sous-systèmes qui n'échangent que des messages.

La persistance est SQLite en mode WAL, embarqué dans le binaire. Rien, sur le chemin par défaut, n'exige un serveur, un démon ou un réseau.

Le contrat d'agent​

Un agent est une fiche, pas un programme : une mission, un modèle, les outils qui lui sont accordés, les sources qu'il peut lire, et ses droits. Un paquet le déclare comme une entrée [[agents]] de son manifeste apollia.toml, et aucun objet Python ne le représente. Ce que le paquet déclare est un ensemble de valeurs par défaut, jamais une contrainte : l'utilisateur change un outil, une source, le modèle ou le palier d'autonomie depuis l'interface, et son réglage local l'emporte sans nouvelle version du paquet.

Le code est optionnel. Le Python d'un paquet apporte des outils et des workflows, des fonctions asynchrones sous @tool et @workflow, et le SDK ne publie aucun autre décorateur. python -m apollia describe écrit leurs descripteurs dans le manifeste, si bien que lire un manifeste n'exécute jamais le code qu'il décrit.

Le runtime lit le manifeste par un seul analyseur, sous un schéma strict. Une clé que le schéma ne connaît pas est refusée avec son chemin complet, de même que ce que le schéma ne sait pas exprimer : un identifiant déclaré deux fois, un accès qui nomme un outil de paquet que le paquet ne livre pas, un paquet qui ne déclare rien. Un paquet dont le manifeste ne valide pas n'est ni construit ni installé.

Le runtime remet à chaque outil et à chaque workflow un objet ctx qui expose les services dont il peut se servir. Ce qu'il porte dépend de l'appelant. Un outil de paquet reçoit un logger et l'identité de l'agent qui l'a appelé, sans modèle, sans proxy d'outils et sans mémoire : c'est le moteur de l'agent appelant qui les fait tourner, pas l'outil. Un workflow de paquet y ajoute les modèles (ctx.llm), les agents et les workflows qu'il peut lancer (ctx.agents, ctx.workflows) et une question à l'utilisateur (ctx.user). ctx est toute la surface du runtime. Un code qui passe à côté utilise quelque chose que le runtime ne garantit pas.

Les schémas de charge utile sont des TypedDict, lus au moment où une fonction est décorée pour construire le schéma de ses entrées. C'est pourquoi l'évaluation différée des annotations est refusée dans ces modules : elle transforme les annotations en chaînes, et le schéma ressort vide sans la moindre erreur.

Le SDK Python n'a aucune dépendance tierce. Chacune deviendrait une dépendance de chaque paquet, sur chaque machine, pour toujours.

Ce contrat en a remplacé un autre le 30 septembre 2026, quand le principe 3 a été réécrit. Jusque-là, un agent était une classe Python sous @agent, avec des méthodes @skill ou @on_message, et le pont refusait tout objet dépourvu du point d'entrée de dispatch que ces décorateurs installaient. Deux constats ont conduit au changement. Un agent qui fixe dans le code les outils qu'il exige est un workflow écrit en code : la personne qui s'en sert ne peut ni ajouter un outil ni changer un droit sans nouvelle version. Et un seul nom recouvrait des objets dont le contrôle n'était pas tenu par la même partie, le modèle dans un cas et le code de l'auteur dans l'autre, si bien que personne ne pouvait savoir lequel il avait devant lui. Le coût est une rupture : un agent écrit comme une classe ne se charge plus, et se réécrit comme une entrée de manifeste dont le Python passe dans des outils ou dans un workflow.

Modèle d'exécution​

Deux choses s'exécutent, et une seule question les distingue : qui décide de l'étape suivante. Pour un agent, c'est le modèle. Pour un workflow, c'est l'auteur.

Un agent tourne sur le moteur autonome, qui observe, raisonne et agit en boucle. Sa mission est le prompt système, les accès qui lui sont accordés sont les outils proposés au modèle, et son modèle comme son palier d'autonomie sont résolus à l'envoi de chaque message, si bien qu'un changement sur sa fiche s'applique à une conversation déjà ouverte. Le moteur fonctionne selon deux modes : direct, où le modèle répond et appelle des outils au fil de l'eau, et orchestré, où un plan est construit puis ses étapes exécutées. Le réglage de planification de l'agent choisit : never, always, ou auto, qui planifie quand la tâche se lit comme une tâche à plusieurs étapes.

Un workflow est du Python dont les étapes sont déclarées d'avance. Son corps ouvre chaque étape par son nom, et un nom absent de la déclaration est refusé. Une étape peut lancer un agent ou un autre workflow : l'exécution imbriquée s'arrête avec celle qui l'a lancée, dépense le même budget, et l'imbrication est bornée à trois niveaux, la première exécution comptant pour un. Le coût de ce partage est qu'une suite fixe ne peut plus passer pour un agent. Elle s'écrit comme un workflow, sa structure est visible, et son utilisateur change ses entrées, pas ses étapes.

Sur le chemin orchestré, les arguments d'étape sont remplis au moment de la planification, sous grammaire, avec une extraction à la volée en repli au moment de l'exécution. C'est ce remplissage à la planification qui permet à un plan de piloter de vrais outils avec des arguments structurés, au lieu de réanalyser de la prose à chaque étape.

Une exécution orchestrée terminée est vérifiée par un critique à partir du palier supervised ; le palier assisted ne lance aucune passe de vérification. Le verdict est consigné comme événement du runtime, et un verdict négatif déclenche une replanification bornée, sous le budget qui bornait déjà l'exécution d'origine.

Budget et garde-fous​

Toute exécution porte un budget d'étapes à trois dimensions : un nombre maximal d'étapes, un nombre maximal d'appels d'outils, et un plafond de temps réel. Le runtime l'applique. Le code de l'agent ne peut ni l'augmenter, ni l'étendre, ni s'en soustraire, parce que le compteur vit du côté runtime du pont et que l'agent n'en détient jamais de référence.

C'est le garde-fou sur lequel repose la conception : une boucle autonome qu'on peut arrêter est un produit, une qu'on ne peut pas arrêter est un risque.

Supervision​

Le superviseur applique l'échec rapide puis la dégradation. Un acteur qui meurt n'est pas relancé. Le runtime signale la perte, dégrade la capacité que cet acteur servait, et continue de servir tout le reste.

Le redémarrage sur crash a été envisagé puis écarté : un acteur qui plante a déjà perdu l'état qu'il détenait, et le relancer produit un sous-système qui répond avec une vue plausible mais vide. Une capacité honnêtement absente s'exploite mieux qu'une capacité qui ment discrètement.

Outils et confinement​

Les outils sont résolus au démarrage, pas au premier appel, si bien qu'un outil absent ou mal configuré est une erreur de démarrage plutôt qu'un échec au milieu d'une exécution.

Le jeu d'outils natifs couvre l'exécution shell, la lecture et l'écriture de fichiers confinées à un chemin, l'exécution Python dans un environnement virtuel propre à l'agent, la récupération HTTP, la recherche web, et la recherche et l'écriture en mémoire. Les outils de fichiers sont confinés à une racine résolue : le chemin est canonisé puis revérifié contre la racine après résolution, donc un lien symbolique ne permet pas d'en sortir.

La racine des outils de fichiers d'un agent est l'union des dossiers accordés à cet agent, agent de conversation intégré compris, sur le chemin de la conversation comme sur celui des tâches. Un dossier du poste est une connexion, choisie au sélecteur du système, et ses outils s'accordent à un agent un par un : les outils de lecture atteignent tous les dossiers qui lui sont accordés, les outils d'écriture seulement ceux accordés pour écrire. Le premier dossier accordé, par date d'ajout, sert d'ancre à un chemin relatif. Un agent sans dossier accordé a des outils de fichiers qui refusent avec un message disant comment en accorder un. Rien d'autre n'est une racine : ni le dossier personnel, ni le dossier de données, ni le volume, ni le dossier d'un projet. Les anciennes clés [filesystem] trusted_paths et [chat] default_workspace sont retirées ; les dossiers qu'elles nommaient, dossier personnel excepté, ont été ajoutés une fois comme connexions, accordées à aucun agent.

Le confinement n'est pas uniforme selon les plateformes, et la différence n'est pas cosmétique. Sous Linux, l'exécution shell tourne sous espaces de noms PID et mount. Sous macOS le confinement est plus faible. Sous Windows il n'y en a pas. C'est dit franchement plutôt que lissé, parce qu'un opérateur qui choisit une plateforme choisit un modèle de menace. Voir le modèle de confiance des agents pour ce que cela implique en pratique.

HTTP sortant​

Tout appel HTTP sortant passe par un seul module, apollia_core::net, et scripts/check_http_clients.py refuse un client construit ailleurs ou un corps de réponse consommé par un autre moyen.

Le module porte deux règles. Une destination doit se résoudre hors des plages de bouclage, privées, lien-local, multicast et unique-local, et hors des suffixes de domaine internes, vérifié sur la première URL puis à chaque saut de redirection, donc une URL publique ne peut pas rediriger vers le réseau local. Un corps de réponse est lu à travers un plafond qui abandonne le flux dès que le budget est franchi, plutôt que de tamponner la réponse entière et de la mesurer ensuite, quand la mémoire est déjà dépensée.

Une exception est nommée plutôt que laissée implicite. Une destination que l'opérateur a configurée peut légitimement être interne : un serveur MCP local, le runner, un point d'accès de modèle auto-hébergé. Ces clients-là sont construits par un constructeur distinct, qui garde le plafond de corps et laisse tomber la vérification d'adresse.

Tenir les deux règles dans apollia-core est ce qui met reqwest et url dans la crate dont toutes les autres dépendent. Elles sont derrière une option de compilation net. Certaines des crates qui dépendent d'apollia-core l'activent ; les autres gardent la fermeture de dépendances qu'elles avaient, et aucune des deux n'est nouvelle dans l'arbre : les deux étaient déjà des dépendances de l'espace de travail, et le fichier de verrouillage n'a gagné aucun paquet. Le coût est que la crate de socle n'est plus sans dépendance dans toutes les configurations. Il vaut la peine d'être payé : la politique d'adresses existait auparavant en deux copies et la lecture plafonnée en six, et chaque règle était tout simplement absente partout où sa copie n'avait pas été faite.

Modèle de permissions​

Un appel d'outil d'un agent est décidé dans un ordre fixe, le même dans une conversation, dans une tâche, dans une étape de plan et dans un élément de lot. La politique de l'organisation est lue en premier, puis les règles fines, qui sont évaluées à chaque invocation contre le premier argument de cet appel-ci : un refus qui correspond rejette l'appel sur-le-champ. Un outil dont la famille ne fait que lire s'exécute ensuite. Tout ce qui est sensible s'exécute sur un accord permanent de l'agent pour la famille de l'outil, ou sur un accord donné pour l'exécution. Ce qui reste déclenche une demande d'approbation et attend une personne, qui répond une fois, toujours ou refuser ; un refus n'exécute rien, et une demande restée cinq minutes sans réponse dans une conversation non plus.

Une règle fine porte l'une de deux portées : un agent, ou tous les agents. Un accord permanent appartient à un agent et à une famille d'action. Rien n'a pour portée un projet ou une session ; ce qui dure le temps d'une exécution est l'accord qu'une réponse donne à cette exécution.

Le coût est une granularité grossière, et elle est choisie : « toujours » couvre une famille, pas un outil, si bien que la personne répond à une question qu'elle peut garder en tête (cet agent peut-il écrire des fichiers) plutôt qu'à une question par nom d'outil, et qu'un outil accordé plus tard dans une famille déjà consentie s'exécute sans demander.

Les exécuteurs de code ne sont jamais couverts par un accord permanent. Consentir à une famille est en général une décision de confort à propos de la lecture ou de l'écriture de fichiers, et elle ne doit pas devenir en silence le droit d'exécuter du code arbitraire.

Une règle ne dispense une commande shell d'approbation qu'en mode développeur, et lorsque cette commande est une commande simple et unique, sans chaînage, tube, redirection, substitution ni exécution en arrière-plan. Cette garde décide d'une dispense d'approbation ; ce n'est pas un filtre placé devant l'exécution, et un exécuteur de code sans règle correspondante demande une personne à chaque invocation.

La décision de permission est écrite dans le journal signé. Une réponse à une demande de consentement (une fois, toujours pour cet agent, ou refuser), un refus par la politique de l'organisation et un refus par une règle deviennent chacun une entrée, qui nomme l'agent, l'outil et sa famille d'action et ne porte jamais l'argument de l'appel. La table permission_audit que porte un governance.db plus ancien n'a jamais été écrite : le fichier la perd à sa première ouverture.

Humain dans la boucle​

Tout outil peut exiger une approbation humaine avant de s'exécuter. Quand c'est le cas, le runtime suspend l'exécution, émet un événement portant ce qui est demandé, et reprend sur la réponse. La suspension est un état de premier rang, pas une attente bloquante : le processus est libre, et une exécution peut rester en attente au travers d'un redémarrage.

Un workflow pose une question à l'utilisateur depuis une étape, par ctx.user.ask : une question, les choix qu'elle propose quand elle en a, et une réponse libre que l'utilisateur peut toujours donner à la place. La question est listée avec les autres décisions qui attendent l'utilisateur, avec l'exécution, le workflow et l'étape dont elle vient, et la réponse réveille l'étape là où elle s'est arrêtée. Cette attente n'est pas un état suspendu : un workflow s'exécute en mémoire et ne reprend pas, donc une exécution qui attend une réponse quand le runtime s'arrête est mise en échec au démarrage suivant, avec cette raison, comme toute exécution interrompue. Une question refusée arrive à l'étape sous la forme de QuestionDeclined, que l'auteur peut intercepter.

Mémoire​

La mémoire a trois couches : les événements épisodiques avec un score d'importance, les faits sémantiques avec un score de confiance, et les procédures avec leurs déclencheurs. Chaque agent a son propre magasin, isolé par espace de noms, interrogeable en texte intégral via SQLite FTS5 avec classement BM25.

La mémoire est lue à l'initiative de l'agent. Le runtime n'injecte jamais de contenu mémoire dans le prompt d'un agent. Un agent qui veut du contexte le demande, ce qui garde le prompt sous le contrôle de l'auteur de l'agent.

La mémoire est écrite à l'initiative de l'agent, elle aussi. Un agent sans code garde une note avec l'outil memory_write et la retrouve avec memory_search ; rien n'est écrit à sa place. Cocher Mémoire sur la fiche d'un agent est la seule chose qui ouvre les deux outils, quoi que listent ses accès, et aucun ne demande d'accord : l'espace est celui de l'agent, ouvert par la personne, et il n'atteint ni ses données ni la machine. Cet espace est nommé d'après l'agent et gardé d'une conversation et d'une tâche à l'autre : un espace par conversation ne laisserait aucune note survivre à la conversation qui l'a écrite. Les deux outils sont tenus hors de la famille qui lit les données personnelles, parce qu'un accord donné pour cette famille couvre aussi la messagerie et les agendas.

Le profil est le cas inverse. Il suit la personne dans chaque conversation où il est activé, donc un agent ne l'écrit jamais. Un agent dont le profil est coché peut proposer un fait avec profile_propose ; l'appel est soumis à la personne à chaque fois, le fait affiché, et aucun accord permanent ni accord d'exécution ne le couvre. Un fait accepté est gardé parmi les faits du profil, avec l'agent pour auteur. Les champs du formulaire de profil ne sont pas des faits et sont refusés.

Trois exceptions existent, et aucune n'est atteignable depuis un chemin d'exécution d'agent. Deux vivent à l'intérieur de l'assistant conversationnel intégré : un résumé du profil de la conversation, et les résumés de sessions passées au premier message d'une conversation libre. La troisième vit en dehors de l'assistant, dans la commande de réécriture du bureau, qui porte le rôle, la fonction et les outils de ce profil dans son propre prompt ponctuel et rend du texte au composeur plutôt qu'à une exécution.

Le résumé du profil est ajouté à chaque tour de l'assistant, à tous les paliers d'autonomie, tant que l'interrupteur de profil de la conversation est allumé. L'interrupteur est allumé par défaut, enregistré avec la conversation, et la jauge de la conversation dit si le prompt porte le profil. Ce résumé était auparavant conditionné par le palier d'autonomie le plus long, que le chat libre n'atteint jamais : un profil rempli par l'utilisateur n'arrivait donc jamais à l'assistant. Un réglage sans effet est pire qu'une injection que l'utilisateur voit et peut couper : la condition est passée du palier à la conversation. La règle des agents est inchangée : un agent installé ne lit le profil que lorsqu'il le demande.

Inférence locale​

Les modèles locaux tournent via un llama-server embarqué, le serveur amont de llama.cpp, que le démon lance et supervise à travers son API HTTP compatible OpenAI, avec appel d'outils natif et traitement continu par lots.

Un binding intégré à l'arbre a été maintenu par le passé, puis abandonné. Le garder revenait à suivre un amont qui bouge vite à travers une couche d'interface étrangère, et chaque capacité qui arrivait en amont arrivait ici en retard, ou pas du tout. Parler l'API HTTP amont coûte un processus local et achète la cadence de publication de l'amont.

Une compilation empaquetée installe le binaire du moteur. Une compilation depuis les sources attend llama-server sur le PATH. Quand un backend local est configuré et qu'aucun moteur n'est joignable, les appels échouent avec une raison d'indisponibilité explicite, sans repli silencieux vers un fournisseur cloud.

Sur macOS Apple Silicon, les modèles au format MLX tournent via un second moteur embarqué, vllm-mlx (Apache 2.0), un serveur compatible OpenAI bâti sur MLX, avec traitement continu par lots, cache de préfixe et sortie structurée native. Une version est épinglée, et le moteur est installé par empreinte dans un environnement à part, dans l'arbre Python embarqué, séparé des paquets qu'importent les agents : le moteur démarre avec l'interpréteur embarqué dans un mode qui tient les paquets des agents hors de son chemin. Seules les dépendances qu'importe le service d'un modèle sont installées, sans résoudre le reste de la fermeture que le moteur déclare. Deux exclusions en découlent. mlx-embeddings est sous GPL-3.0 et ne sert que le point d'entrée de plongements du moteur : il n'est jamais livré et ce point d'entrée n'est pas servi ; la construction comme la vérification de publication refusent un paquet qui le porte. PyTorch, OpenCV, Gradio, SciPy et la pile audio ne sont pas importés pour servir un modèle : ils sont laissés de côté. Le démon supervise le moteur comme il supervise llama-server, derrière une interface de moteur qui garde le moteur remplaçable, et il n'écoute que sur l'interface de boucle locale. Le prix est le poids : le moteur et ses dépendances ajoutent environ 150 Mo à l'image disque macOS, et ils demandent macOS 14 ou plus récent, plancher des roues MLX à la version épinglée. Les paquets Windows et Linux ne le portent pas.

Les moteurs ont leur propre canal de mise à jour et ne suivent pas la version d'Apollia. Le moteur embarqué sert chaque machine dès le premier lancement ; une version accélérée, CUDA sur une machine dotée d'une carte NVIDIA, se télécharge à la demande, ou s'importe depuis un disque sans réseau, et le lanceur prend alors la version installée la plus récente de cette variante plutôt que le moteur embarqué. Le moteur qui sert chaque format de modèle est un réglage, automatique par défaut ; une variante choisie qui n'est plus installée retombe sur le choix automatique. Le canal suivi est vérifié au démarrage du runtime et au changement de canal, et une mise à jour qu'il annonce pour un moteur choisi est installée sans qu'on la demande, sauf si le réglage qui la gouverne est éteint, auquel cas elle est seulement annoncée. Tout téléchargement ou import de moteur est précédé de la vérification d'un manifeste d'empreintes signé au format minisign avec une clé de distribution dédiée. Chaque canal, stable ou préversion, publie un tel manifeste, qui liste chaque archive de moteur avec son SHA-256. La moitié publique de cette clé est compilée dans le binaire et c'est la seule clé acceptée. Aucune archive n'est demandée avant que la signature tienne, une archive dont l'empreinte diffère du manifeste est supprimée, et un manifeste plus ancien que le dernier accepté sur son canal est refusé. Un miroir peut remplacer l'adresse d'où viennent manifestes et archives ; il ne peut pas remplacer la clé. Les archives de moteur se lisent au format zip seul, celui déjà lu pour les paquets d'agents, et aucun autre format d'archive n'est analysé. Les versions amont publiées en .tar.gz, dont celles pour macOS et Linux, sont reconditionnées en zip par la chaîne de publication qui produit le manifeste, et le manifeste porte l'empreinte de ce zip. L'environnement du moteur MLX figure dans le même manifeste et s'installe par la même vérification.

Il n'y a aucun fournisseur cloud dédié. Un modèle distant se joint par une seule connexion générique à un serveur compatible OpenAI, à l'adresse que l'organisation désigne : un vLLM ou un LM Studio auto-hébergé, ou une passerelle. Toute adresse compatible OpenAI est acceptée, mais une API cloud n'est ni testée ni promue. Quand un modèle cloud est nécessaire, LiteLLM est la passerelle recommandée : elle tourne sur une infrastructure que l'organisation maîtrise et parle le protocole OpenAI à Apollia. À la migration de system.db, les backends qui visaient les API d'OpenAI, d'Anthropic ou de Mistral sont supprimés, et ceux qui visent un serveur auto-hébergé sont gardés tels quels.

Réessayer une réponse en échec​

Une erreur serveur d'un moteur d'inférence est réessayée pendant une minute au plus, avec un délai exponentiel, puis signalée. Le client HTTP des backends compatibles OpenAI, async-openai, réessaie par défaut chaque erreur 5xx pendant quinze minutes, et un moteur qui refuse une requête pour ce qu'elle est (un gabarit de conversation qui rejette la forme de l'échange répond 500 à chaque fois) a tenu un élément de lot un quart d'heure avant qu'il échoue. Borner cette reprise demande de remettre au client une politique de la bibliothèque backoff, celle avec laquelle il réessaie : backoff 0.4 est donc déclarée dans le manifeste d'apollia-llm, sans fonctionnalité, sous la fonctionnalité cloud qui apporte déjà async-openai. Elle n'ajoute aucun code à la compilation : la bibliothèque figurait déjà dans le fichier de verrouillage par async-openai. Elle est signalée comme non maintenue (RUSTSEC-2025-0012, et RUSTSEC-2024-0384 pour instant en dessous), exemptée dans deny.toml tant qu'async-openai réessaie avec elle ; le jour où le client s'en passe, la déclaration part avec elle.

Vérifier un modèle téléchargé​

Chaque fichier d'un modèle téléchargé depuis HuggingFace est vérifié contre l'identifiant que le dépôt publie pour lui avant que le modèle soit inscrit. Un fichier stocké par LFS (poids, projecteur, gros tokenizer) est vérifié contre le SHA-256 de son contenu. Un petit fichier commité hors LFS (config.json, tokenizer_config.json et leurs pareils dans un dépôt MLX) n'a pas de SHA-256 publié : HuggingFace ne publie que son identifiant de blob git, le SHA-1 de blob <taille>\0 suivi du contenu. Un tel fichier est vérifié contre cet identifiant, et c'est pourquoi sha1 est une dépendance directe de apollia-llm. Elle vient de la même famille RustCrypto que sha2, n'ajoute aucune crate transitive, et sert à comparer un fichier à ce que son dépôt déclare, non à résister à un faussaire : la connexion TLS au dépôt porte cette garantie, et un fichier qui ne correspond à aucun des deux identifiants est supprimé.

Le registre des modèles​

Le catalogue des modèles lit un index en ligne de fiches de modèles. Une fiche est un texte court rédigé pour ce projet, en français et en anglais : ce que le modèle sait bien faire, les capacités que déclare son gabarit de conversation, la mémoire qu'il demande, les valeurs d'échantillonnage que recommande son éditeur, les réglages qu'expose son gabarit, sa licence et la page de son dépôt d'origine. Chaque valeur qu'une fiche reprend d'un dépôt de modèle cite l'adresse où elle a été lue ; aucun texte de README n'est recopié dans une fiche, et un README n'est lu depuis le dépôt qu'à la demande de l'utilisateur, en texte, sans jamais être écrit sur le disque. Les fiches sont écrites dans ce dépôt, pour qu'une fiche soit ajoutée ou corrigée sans nouvelle version de l'application.

Le registre est lu sous le principe du local d'abord. L'index est un fichier statique, demandé à l'ouverture du catalogue et à aucun autre moment, par une requête sans corps, sans paramètre, sans cookie et sans identifiant : le registre n'apprend rien de qui le lit, et aucune donnée de l'utilisateur n'est envoyée. Il est signé au format minisign par la clé de distribution dédiée décrite dans Signatures, dont la moitié publique est compilée dans le binaire et est la seule clé acceptée ; un index dont la signature ne tient pas est refusé, jamais utilisé. L'index est servi depuis un stockage objet opéré en France, derrière un domaine du projet, avec des journaux d'accès conservés peu de temps. Quand cette adresse ne répond pas, le même index signé est lu depuis le miroir des versions publiées sur GitHub, avec la même vérification. Le dernier index accepté est gardé sur le disque, et la fiche d'un modèle est copiée à côté de lui à son téléchargement : un modèle installé lit sa fiche hors ligne.

Une organisation remplace l'adresse par un miroir interne, ou coupe le registre, par la section [models] d'apollia.toml. Un miroir change l'origine de l'index, jamais qui peut le signer.

L'index construit à partir des fiches au moment où l'application a été faite est livré dans le binaire. Le catalogue le lit quand aucune adresse ne répond et qu'aucune copie signée n'est gardée, et dans une version qui ne porte pas de clé de distribution : un premier lancement sans réseau montre toutes les fiches que la version connaissait. Il ne porte pas de signature parce qu'il n'en a pas besoin, faisant partie du binaire dont la signature le couvre, et il ne remplace jamais un index signé : un index lu à une adresse ou dans la copie gardée passe avant lui, sous la vérification décrite plus haut. Un registre coupé par la configuration ou par la politique reste coupé, et l'index livré n'est pas lu non plus. Une garde échoue quand l'index livré ne correspond plus aux fiches.

Le coût est que les fiches d'un poste qui n'atteint jamais le registre ont l'âge de sa version.

Un éditeur est montré avec l'image de son organisation sur HuggingFace. Aucune image n'est livrée avec l'application : chacune est demandée à l'ouverture du catalogue, par une requête qui nomme l'organisation et rien d'autre, acceptée seulement de cet hôte en HTTPS, dans un format matriciel et sous un plafond de taille, puis gardée dans le dossier de données, si bien qu'elle est demandée une fois par poste. Un éditeur dont l'image n'a pas pu être lue est montré par ses initiales. Un registre coupé ne demande aucune image.

Reconnaissance vocale​

La transcription tourne hors processus, dans un sidecar bâti sur whisper, si bien qu'un plantage de modèle ne peut pas emporter le démon. Elle est optionnelle : une compilation sans elle perd la dictée, et rien d'autre.

Model Context Protocol​

Apollia est un client MCP sur les transports stdio et HTTP. C'est aussi un serveur MCP, pour les agents et workflows que l'utilisateur choisit et rien d'autre : apollia-os mcp server est un pont stdio vers le démon en cours, qui liste les agents et workflows marqués exposés et en exécute un jusqu'à sa fin quand un client l'appelle. Un agent prend un message, sous le nom d'outil agent__<id> ; un workflow prend les entrées que son schéma déclare, sous workflow__<paquet>_<workflow>. Un nom s'en tient à [A-Za-z0-9_-] et à 64 caractères, comme l'exigent les API des modèles, et deux éléments dont les noms se confondraient sont tous deux écartés. Le pont est désactivé par défaut : le démon le refuse tant que [mcp] server_enabled n'est pas activé, réglage qu'il relit à chaque ouverture d'un pont. Les outils natifs (shell, fichiers, installation d'agents) ne sont plus servis à un client MCP.

Toute réponse MCP est traitée comme une entrée non fiable. Les réponses sont plafonnées par serveur, les noms et descriptions d'outils sont validés dès l'ingestion contre un jeu de caractères et une longueur maximale, et le nombre d'outils qu'un seul serveur peut apporter est borné. Un serveur qui se comporte mal se dégrade lui-même, pas le runtime.

Les serveurs qui exigent OAuth passent par un flux dédié, avec son propre plafond de réponse, plus petit.

Node.js n'est pas embarqué dans Apollia, ce qui tient le principe 2 (zéro dépendance externe) : rien n'est à installer sur le poste au préalable. Une version LTS épinglée de Node.js est publiée dans le manifeste d'empreintes signé du canal des moteurs, signé au format minisign avec la clé de distribution dédiée d'Apollia (voir Inférence locale), et elle se télécharge au premier serveur lancé par node ou npx qui en a besoin : la signature du manifeste est vérifiée avant toute requête d'archive, puis l'empreinte de l'archive, puis l'archive est extraite dans le magasin des moteurs. Un tel serveur tourne alors sur ce Node.js par son chemin absolu, jamais par le PATH ; tant qu'aucun n'est installé, son lancement échoue sur une erreur typée « Node.js requis ». Un poste sans réseau importe Node.js depuis un disque, par la même vérification. Les paquets npm des serveurs s'installent depuis un verrou qui porte une empreinte d'intégrité par paquet.

Connecteurs​

Les connecteurs Google et Microsoft s'authentifient en OAuth2 avec PKCE. Aucun agrégateur ne s'intercale : un relais tiers payant entre un opérateur et sa propre boîte mail contredit la raison d'être du produit.

Le binaire embarque un client OAuth Microsoft et aucun client Google. Les portées restreintes de Google exigent un processus de vérification que le projet n'a pas mené, donc une connexion Google demande à l'opérateur de fournir ses propres identifiants client. L'asymétrie est assumée, et elle est énoncée là où l'opérateur la rencontre.

Documents et sources​

Les classeurs et les fichiers CSV se lisent en Rust, sans l'interpréteur Python embarqué. calamine (MIT) lit .xlsx, .xlsm, .xlsb, .xls et .ods ; csv (MIT ou Unlicense) lit les fichiers CSV, avec le séparateur détecté parmi la virgule, le point-virgule et la tabulation. Les deux sont en pur Rust, en lecture seule, ne lient aucune bibliothèque système et n'ouvrent aucune connexion. Chaque cellule se lit en texte avec sa feuille, sa ligne et sa colonne : un nombre sans partie décimale nulle, une date en ISO 8601, une formule par la valeur que le fichier a calculée en dernier (aucune formule n'est évaluée), une erreur par son code. Un classeur protégé par un mot de passe, ou endommagé, donne une erreur typée, jamais un plantage. Un fichier CSV qui n'est pas en UTF-8 se lit avec ses octets invalides remplacés, et le résultat le signale : aucun encodage n'est deviné.

Le coût est nommé : calamine dépend de zip 8 quand l'espace de travail porte zip 4 pour l'archive de paquet et l'export d'un lot ; deux versions majeures de zip entrent donc dans chaque compilation, avec cinq petites crates sous calamine (atoi_simd, codepage, debug_unsafe, fast-float2, typed-path) et csv-core sous csv. calamine est épinglé en 0.36.1 : une nouvelle version, et la version de zip qu'elle apporte, est une décision et non une mise à jour.

Le texte d'un document PDF s'extrait en Rust par pdf_oxide (MIT ou Apache-2.0), page par page, chaque page avec son numéro. La bibliothèque est en pur Rust, en lecture seule, ne lie aucune bibliothèque système et n'ouvre aucune connexion ; ses fonctions par défaut sont désactivées, si bien que ni son moteur de profils colorimétriques ni ses modules de rendu, d'OCR ou de signature n'entrent dans la compilation, et seule legacy-crypto est gardée, pour qu'un document verrouillé par un seul mot de passe propriétaire s'ouvre encore. Une page qui ne dessine ni lettre ni chiffre est marquée sans texte : c'est la page que la reconnaissance optique de caractères devra lire. Un document qui demande un mot de passe pour s'ouvrir, ou endommagé, donne une erreur typée, et un défaut de l'analyseur sur un fichier malformé est rattrapé et signalé comme un fichier endommagé, jamais comme un plantage. La bibliothèque a été mesurée avant d'être adoptée, sur vingt documents français réels publiés sous Licence Ouverte (pièces de marché et conditions d'utilisation, budgets et grilles tarifaires, notes méthodologiques), avec cinq phrases témoins accentuées par document, prises à un extracteur indépendant : dix-neuf documents sur vingt rendent leurs cinq phrases, tous les accents intacts ; le vingtième perd une phrase dont une cellule de tableau répartit les mots sur deux lignes. Le corpus est versionné avec les tests qui le lisent, et PDFium, le repli natif envisagé, n'est pas livré.

Le coût est nommé : pdf_oxide apporte cinquante-trois versions de crates absentes du fichier de verrouillage, dont une pile de polices (skrifa, read-fonts, write-fonts, ttf-parser, subsetter), un moteur de mise en page (taffy) et des secondes versions majeures de quick-xml, brotli, sha2 et aes. pdf_oxide est épinglé en 0.3.78, et office_oxide, dont il dépend, est tenu en 0.1.11 dans le manifeste de l'espace de travail et dans le fichier de verrouillage : sa version 0.1.12 ne compile plus sous pdf_oxide 0.3.78 ; mettre l'une ou l'autre à jour est donc une décision et non un rafraîchissement. Le manifeste le tient parce qu'une construction résolue sans le fichier de verrouillage prendrait sinon la version plus récente.

Une crate de cette pile de polices porte un avis : ttf-parser 0.25.1, dont seul pdf_oxide se sert pour lire les polices qu'un document embarque, est signalée non maintenue (RUSTSEC-2026-0192). Aucune faille n'y est connue et aucune version plus récente n'existe vers laquelle monter : l'avis est donc exempté dans deny.toml, daté du 2026-10-04, avec cette raison à côté, et il est revu à la prochaine version mineure. L'exemption tombe quand pdf_oxide abandonne ttf-parser ou qu'une version maintenue en paraît.

Les documents Word et les présentations PowerPoint sont lus en Rust, par un code propre posé sur deux crates : zip, celle qui ouvre l'archive de paquet (voir Archive de paquet), ouvre le fichier, et quick-xml (MIT), un lecteur XML en flux, en lit les parties. Un document Word rend les paragraphes et les lignes de tableau de son corps, une ligne par segment, dans l'ordre du document et chacun avec son rang ; une présentation rend le texte et les notes de chaque diapositive, dans l'ordre numérique, chacune avec son numéro. Les en-têtes, pieds de page, commentaires et zones de texte flottantes ne sont pas lus. Un fichier aux anciens formats binaires, .doc ou .ppt, est une erreur typée qui nomme les formats lus. L'archive vient d'ailleurs, sa lecture est donc bornée : au-delà de 10 000 entrées, ou de 64 Mio décompressés sur l'ensemble des parties lues, le fichier est refusé, les octets étant comptés à mesure qu'ils sont décompressés et non tels que l'archive les déclare. Une crate dédiée n'a pas été retenue : docx-rs, celle pesée pour Word, est tournée vers l'écriture et ne lit pas PowerPoint, et office_oxide, qu'apporte le lecteur PDF, est tenue à une ancienne version (voir plus haut) et n'est pas appelée directement.

Le coût est nommé : aucune crate n'entre dans le fichier de verrouillage. quick-xml 0.41 était déjà compilée pour le lecteur de classeurs et le paquet du bureau, et devient une dépendance directe, fonctionnalités par défaut coupées ; zip était déjà une dépendance directe. Ce que ces crates ne couvrent pas est à notre charge : la structure des deux formats, lue ici jusqu'à leur texte seulement.

Les pages scannées et les images sont lues par reconnaissance optique de caractères, avec un moteur compilé dans le binaire. Une page de document PDF qui ne trace aucun texte et qui est une image unique est prise telle que le document l'embarque, sans rendu de la page, puis reconnue ; les fichiers .png, .jpg et .tiff d'une source sont lus de la même façon. Le moteur est Tesseract, sur Leptonica, tous deux compilés depuis leurs sources par tesseract-rs (MIT ; Tesseract est sous Apache-2.0, Leptonica sous BSD-2-Clause) et liés statiquement : le poste qui exécute Apollia n'installe rien, et le binaire ne dépend d'aucune bibliothèque Tesseract ou Leptonica. Leptonica est compilée sans ses codecs d'image, les images sont donc décodées en Rust par image (MIT ou Apache-2.0), avec ses seuls décodeurs PNG, JPEG et TIFF. Le texte reconnu vit dans l'index de la source et nulle part ailleurs : aucun fichier n'est écrit à côté des documents.

Le moteur lit avec un fichier de données par langue. Le français et l'anglais, tirés du jeu tessdata_fast (Apache-2.0), sont des entrées du registre des modèles, chacune épinglée par son SHA-256. Ils sont obtenus la première fois qu'une source contient une page scannée, une fois par exécution, par le téléchargeur qui contrôle chaque modèle contre son empreinte ; un poste sans réseau prend les mêmes fichiers sur un disque par l'import de modèle, qui n'accepte un fichier de données de langue que si son empreinte est épinglée. Un fichier dont l'empreinte n'est pas épinglée n'est jamais remis au moteur. Tant que les données manquent, chaque page en attente est listée dans l'état de sa source comme en attente de reconnaissance, et elle est lue dès qu'elles arrivent ; une page sans texte qui n'est pas une image unique est listée comme non lue. Aucun moteur de rendu n'est livré : hayro et PDFium, ceux qui ont été pesés, restent dehors tant que les documents n'en montrent pas le besoin.

Le coût est nommé : la chaîne C++ entre dans la compilation. Compiler le moteur demande cmake, un compilateur C++ et le réseau, prend une à deux minutes (83 secondes sur macOS Apple Silicon, 133 secondes sur Linux x64 sous émulation), et ajoute 2,8 Mo au binaire sur macOS et 4,4 Mo sur Linux. Le script de compilation de tesseract-rs télécharge les sources de Tesseract 5.5.2 et de Leptonica 1.87.0 depuis GitHub sans en contrôler l'empreinte, les garde avec les bibliothèques compilées dans un dossier du répertoire personnel plutôt que sous target/, et y pose deux fichiers de langue que le produit ne lit pas. La reconnaissance est donc derrière la fonctionnalité ocr, désactivée par défaut : une compilation de tous les jours n'en compile rien, et une compilation qui livre la reconnaissance l'active. tesseract-rs est épinglée à 0.4.0, puisqu'une version change les sources qu'elle va chercher ; trois crates entrent dans le fichier de verrouillage, tesseract-rs elle-même et aws-lc-rs avec aws-lc-sys, que le client HTTP de son script de compilation apporte et qui ne s'exécutent qu'à la compilation. image était déjà compilée pour le lecteur PDF et n'en ajoute aucune. La compilation statique est mesurée sur macOS et sur Linux ; elle ne l'est pas sur Windows, où la fonctionnalité reste désactivée tant qu'elle ne l'est pas.

Les résultats d'un lot sont écrits dans le tableau qu'il a lu, par défaut dans une copie posée à côté de l'original, <nom> (résultats AAAA-MM-JJ).xlsx, nommée dans la langue de l'interface, si bien que l'original est lu et jamais ouvert en écriture. Sur demande explicite, et pour .xlsx et .xlsm seulement, ils vont dans l'original lui-même, après la copie de sauvegarde datée qu'exige le contrat des lots ; si la sauvegarde échoue, rien n'est écrit. Dans les deux cas, le classeur est édité en place, par du code à nous au-dessus de zip et de quick-xml : les cellules de la colonne ajoutée, après la dernière colonne de la feuille, sont insérées dans le XML de cette feuille en chaînes en ligne, la chaîne de calcul est retirée pour que le tableur recalcule à l'ouverture, et toutes les autres parties de l'archive sont recopiées telles qu'elles sont stockées, octet pour octet. La mise en forme, les graphiques, les tableaux croisés, les commentaires et les macros sont donc conservés, ce que ne garantit pas une bibliothèque qui charge un classeur puis l'enregistre de nouveau : umya-spreadsheet, celle qui a été pesée, documente des graphiques corrompus, des commentaires perdus et des tableaux croisés non lus. L'édition est testée sur un corpus versionné de classeurs réels (mise en forme conditionnelle, graphique, tableaux croisés, commentaires, fichier enregistré par LibreOffice, classeur à macros). Un fichier CSV reçoit une copie avec sa colonne ajoutée, jamais une édition de l'original ; un classeur .xls, .xlsb ou .ods reçoit une copie .xlsx neuve écrite par rust_xlsxwriter (voir Export d'un lot), ses valeurs seulement.

Le coût est nommé : aucune crate n'entre dans le fichier de verrouillage, rust_xlsxwriter devenant une dépendance directe des lecteurs. Ce qui est à notre charge est l'édition de la feuille, une petite partie d'un format dont les autres parties ne sont jamais réécrites.

Une boîte mail se connecte en IMAP avec un mot de passe d'application : tout fournisseur est joignable, Gmail compris, sans client OAuth et sans relais. Le client est async-imap (MIT ou Apache-2.0), sur le runtime Tokio, au-dessus de la pile TLS tokio-rustls que le runtime porte déjà ; il n'apporte pas OpenSSL. Les messages sont décodés par mail-parser (Apache-2.0 ou MIT), qui ne dépend de rien hors de Rust. Le certificat du serveur est vérifié contre les certificats racine du système, lus par rustls-native-certs (Apache-2.0, ISC ou MIT), déjà présent dans la compilation par le client HTTP et désormais dépendance directe.

L'accès est en lecture seule, et le client l'impose au lieu de le promettre : les dossiers s'ouvrent par EXAMINE, jamais SELECT, les messages se lisent par BODY.PEEK et restent non lus, et aucune commande d'écriture (STORE, EXPUNGE, COPY, MOVE, APPEND) ne part. Envoyer, déplacer ou supprimer un message est hors d'atteinte. Le mot de passe vit dans le magasin de secrets configuré, jamais dans la liste des connexions, un journal de traces ou le journal d'audit ; comme async-imap trace les commandes qu'il envoie par la façade log, connexion comprise, chaque binaire éteint sa cible de traces.

Le coût est nommé : deux crates et leurs petits arbres (imap-proto, hashify, stop-token, async-channel) entrent dans chaque compilation, pour un protocole dont les serveurs varient dans leur rigueur. Un serveur qui comprend mal une commande donne une erreur de protocole typée, pas un plantage.

Export d'un lot​

Le résultat d'un lot s'exporte en classeur Excel, en CSV ou en JSON, une ligne par élément. CSV et JSON s'écrivent sans dépendance. Le classeur est écrit par rust_xlsxwriter (MIT ou Apache-2.0), un écrivain seul, sans accès réseau, épinglé à 0.92.0 : c'est la dernière version qui dépend de la version majeure de zip que l'espace de travail porte déjà, et une version plus récente mettrait deux versions de zip dans chaque build. Il ajoute trois crates au fichier de verrouillage, lui-même, zopfli et zlib-rs (voir Archive de paquet).

rust_xlsxwriter ne sait pas modifier un classeur existant. Écrire les résultats dans le fichier d'où un lot a été lu se décide avec le lecteur de tableurs (voir Documents et sources).

L'application de bureau écrit un classeur de plus avec lui : le jeu de démonstration sur lequel un agent d'exemple s'essaie, construit depuis un tableau que l'application porte quand l'utilisateur en demande une copie. Aucune crate n'entre pour cela dans le fichier de verrouillage, rust_xlsxwriter devenant une dépendance directe du bureau.

Messagerie entre agents​

Un agent en joint un autre de deux façons. Une étape de workflow lance un agent installé sur un message et attend sa réponse : l'agent tourne avec ses propres accès, imbriqué dans l'exécution qui l'a appelé. Ou bien un message est déposé dans la boîte aux lettres durable d'un agent, adossée à SQLite, et le destinataire le retire à son initiative. La livraison par boîte aux lettres se fait par bail : un consommateur prend un bail, et l'acquittement est verrouillé sur l'exécution qui le détient, si bien qu'un consommateur périmé dont le bail a été réattribué ne peut ni supprimer ni remettre en file le message que le nouveau détenteur est en train de traiter.

L'API liste ce qui peut être appelé sous un identifiant complet, agent:<id> pour un agent et workflow:<package>/<name> pour un workflow, et lance l'un comme l'autre par cet identifiant. Le dispatch le propage en entier. Une clé de dispatch plus courte a été utilisée une fois, et elle a produit de l'ambiguïté dès que deux travailleurs ont exposé des skills aux noms voisins.

Audit et preuve​

Il y a un seul registre : le journal chaîné par hachage.

Ses entrées sont chaînées et signées, et la chaîne est ancrée globalement pour que la troncature soit détectable : retirer la fin du journal casse une vérification qu'un lecteur peut lancer lui-même. Un appel d'outil fait deux entrées, une à son départ et une à sa fin, et un appel qui a échoué est enregistré comme ayant échoué. Une entrée est ajoutée sans bloquer l'exécution, et elle est jetée, avec un avertissement dans les logs, quand le canal est saturé : le journal prouve que ce qu'il contient n'a pas été altéré, pas qu'il contient tout.

Le journal enregistre une exécution par les événements qu'elle émet. Un appel d'outil fait dans une conversation est annoncé avec son exécution, il est donc enregistré ; un appel qu'aucun événement n'annonce avec une exécution ne l'est pas.

Une exécution capturée peut être rejouée contre son propre enregistrement. apollia-os audit replay et POST /api/v1/audit/replay/{run_id} parcourent ce que le journal a capturé pour l'exécution, chaque réponse de modèle suivie des sorties des appels d'outils qu'elle demandait, sans appel de modèle ni exécution d'outil, et répondent identique, divergente ou trace incomplète. Lancer l'agent une seconde fois et comparer les deux exécutions n'est délibérément pas construit : le journal ne conserve pas le prompt dont l'exécution est partie, et une seconde exécution prouve comment la seconde s'est comportée, pas la première. La responsabilité repose sur la chaîne signée, et le coût est qu'un rejeu dit si une capture est cohérente, pas si l'agent agirait de la même façon une nouvelle fois.

Secrets et authentification de l'API​

Les secrets vivent dans le trousseau du système, ou dans un fichier chiffré avec age là où il n'existe pas de trousseau. Ils ne sont jamais écrits dans un fichier de configuration.

L'API HTTP écoute sur une socket Unix et sur un port TCP ; le daemon lie les deux à chaque démarrage, et le numéro de port est la seule chose que --port décide. La socket Unix relève de la confiance locale et s'appuie sur les permissions du système de fichiers. Le TCP exige un jeton porteur sur tous les chemins /api/v1 sauf un, et le lier à une adresse non locale sans jeton est une erreur de démarrage plutôt qu'un avertissement : un attachement distant non authentifié est la seule erreur qu'on ne peut pas défaire une fois que le trafic est passé. TLS est facultatif : avec [api] tls_cert et tls_key renseignés, l'écouteur TCP sert en HTTPS, et un certificat qui ne se charge pas fait échouer le démarrage au lieu de retomber en clair.

L'exception est GET /api/v1/health, qui répond sans jeton et ne révèle que le marqueur de produit et la version du runtime, rien d'autre. Un processus qui trouve le port pris ne détient aucun jeton de cette installation, et ce qu'il doit savoir, c'est si le détenteur est un runtime Apollia et dans quelle version. Toutes les autres routes /api/v1 gardent le jeton.

Le serveur compatible et le réseau local​

Le runtime a deux écouteurs. L'API native, port 7771 et socket locale, pilote le runtime ; le serveur compatible, port 7772 par défaut, sert les formats d'OpenAI et d'Anthropic aux autres applications. L'API native reste sur la machine : le runtime embarqué du bureau la lie à 127.0.0.1 quoi que dise le fichier, le daemon à la boucle locale que [api] bind nomme par défaut (une autre adresse exige un jeton, comme ci-dessus), et aucun réglage du serveur compatible ne la déplace. Le serveur compatible est la seule surface que l'utilisateur ouvre au réseau local, par [server] serve_on_lan, éteint par défaut et allumé seulement par un geste explicite de l'utilisateur.

Pour les modèles, le réseau et les jetons sont deux interrupteurs indépendants. Un appareil du réseau local peut appeler les modèles sans jeton quand [server] require_auth est éteint, comme d'autres serveurs d'inférence locaux le permettent ; ce choix appartient à l'utilisateur. Les agents et les workflows ne sont pas des modèles : ils atteignent les connexions, les documents et le courrier de l'utilisateur, si bien qu'un appel venu du réseau local n'est admis qu'avec un jeton actif du serveur, quoi que dise require_auth. Chaque requête porte son origine, la machine ou le réseau local, lue dans l'adresse du pair à l'acceptation de sa connexion. La politique de l'organisation est l'endroit où une organisation interdit le réseau local ou impose les jetons, par-dessus les interrupteurs de l'utilisateur.

Au regard du principe du local d'abord : rien ne sort de la machine tant que l'utilisateur n'ouvre pas le réseau, et ce qui répond alors sans jeton est une inférence sur le texte que l'appelant a lui-même envoyé. Les données de l'utilisateur, que seuls les agents et les workflows atteignent, ne répondent jamais au réseau sans un jeton que l'utilisateur a créé et peut révoquer. L'API native, qui porte toutes les autres capacités, n'est jamais joignable depuis le réseau par cet interrupteur. Le trafic sur le réseau est du HTTP en clair : le serveur compatible ne termine pas TLS.

Signatures​

Deux schémas de signature coexistent, un par sorte de signataire, et aucun n'a besoin du réseau pour vérifier.

Les paquets d'agents et les politiques d'organisation sont signés par leur auteur, avec une clé que l'application génère. La signature est une signature Ed25519 détachée sur une liste canonique d'entrées (chemin, SHA-256) triée par les octets du chemin : l'ordre dans lequel les fichiers ont été rassemblés ne la change jamais, et ajouter, retirer ou modifier un seul fichier la casse. Elle voyage dans un fichier SIGNATURE versionné qui porte la version du format, l'empreinte de la clé (le SHA-256 de la clé publique), le nom d'éditeur déclaré par l'auteur, et la signature. La clé privée est rangée par le magasin de secrets et n'est jamais écrite en clair sur le disque. Le nom d'éditeur est une déclaration et n'est pas couvert par la signature : ce qui identifie un auteur, c'est l'empreinte de sa clé.

Les métadonnées de distribution publiées par la chaîne de publication d'Apollia (le manifeste d'empreintes des moteurs d'inférence et de Node.js, l'index du registre des modèles) sont signées au format minisign, avec une clé de distribution dédiée. C'est le même format que la clé du programme de mise à jour de l'application de bureau, et une clé distincte, rangée dans ses propres secrets de la chaîne (APOLLIA_DIST_SIGNING_KEY, APOLLIA_DIST_SIGNING_KEY_PASSWORD). La signature se fait dans la chaîne avec l'outil de signature de Tauri ; l'application ne fait que vérifier, et le démon vérifie sans Tauri, ce qui fait du vérificateur une dépendance directe plutôt qu'une dépendance atteinte par le greffon de mise à jour.

minisign n'a pas été retenu pour les paquets et les politiques : il signe un fichier, pas une liste d'entrées, et la génération et le rangement de clé dont l'application a besoin seraient à écrire de toute façon. Sigstore et X.509 ont été écartés pour les deux usages : l'un exige un journal de transparence et un fournisseur d'identité joints par le réseau, l'autre une autorité de certification, et chacun fait d'un tiers une condition de la vérification, contre le principe du local d'abord.

Le coût est une surface de dépendances nouvelle et l'absence de révocation. ed25519-dalek 2 est compilé sans ses fonctionnalités par défaut (pas de tables précalculées) et apporte ed25519 et signature ; son arithmétique de courbe, curve25519-dalek, était déjà dans l'arbre. minisign-verify n'a aucune dépendance propre et était déjà dans l'arbre par le programme de mise à jour. Une clé ne se révoque qu'en changeant l'ensemble des clés auxquelles un lecteur fait confiance.

Politique de l'organisation​

Une organisation impose ses règles à un poste en déposant une politique signée dans un dossier système : /Library/Application Support/Apollia/policy sous macOS, C:\ProgramData\Apollia\policy sous Windows, /etc/apollia/policy sous Linux. Le dossier porte policy.toml, un SIGNATURE au format des paquets ci-dessus sur une liste d'une entrée, et org.pub, la clé publique de l'organisation. Le runtime le lit une fois, au démarrage. Pas de dossier, pas de politique : l'utilisateur est libre.

La clé est rangée à côté de la politique, si bien que la politique ne vaut que par les droits de son dossier. Un dossier ou un fichier qu'un autre que l'administrateur du système peut modifier est refusé : sous Unix le propriétaire doit être root, et ni les autres ni un groupe autre que celui de root ne peuvent écrire ; sous Windows le propriétaire doit être le groupe Administrateurs ou le compte LocalSystem, et aucune entrée de la liste d'accès ne peut accorder une écriture à quelqu'un d'autre. Un lien symbolique est refusé partout. Le chemin Windows est écrit en dur plutôt que lu dans %ProgramData%, que l'utilisateur règle.

La date de signature est un champ signed_at de policy.toml, une date, une heure et un décalage. La signature couvre le fichier entier : la date est donc signée, et le format SIGNATURE n'a pas changé pour elle. Une date du système de fichiers a été écartée : n'importe qui peut toucher un fichier.

Lire le propriétaire et la liste d'accès d'un fichier sous Windows passe par l'API de sécurité Win32, que la bibliothèque standard n'expose pas. windows-sys est une dépendance directe de la crate des permissions sous Windows seulement, avec les fonctionnalités de sécurité, d'autorisation et de système de fichiers. C'est la liaison brute que publie Microsoft, elle n'a d'autre dépendance que sa propre cale d'édition de liens, et la même version était déjà dans l'arbre par d'autres crates : le fichier de verrouillage n'a gagné aucun paquet. Le coût est un bloc de code FFI avec sa propre permission unsafe, confiné à un module.

Le fichier de configuration​

apollia.toml a un seul écrivain, apollia_core::config::writer. Les écrans de réglages de l'application de bureau, la ligne de commande, les routes sous /api/v1/config et les moteurs que le runtime recopie dans le fichier passent tous par lui, si bien que les quatre mêmes vérifications tiennent quel que soit l'auteur : le texte se valide contre le schéma strict, aucune ligne ne donne à une clé que la politique de l'organisation impose une autre valeur, le fichier sur le disque est encore celui que l'appelant a lu, comparé par son empreinte SHA-256, et le texte remplace le fichier par un fichier voisin renommé par-dessus. Une écriture par clé garde les commentaires et l'ordre du fichier. La ligne de commande appelle l'écrivain directement : elle fonctionne démon arrêté, et un refus se lit dans la même phrase sur chaque surface.

Tenir l'écrivain dans la crate de fondation place toml, toml_edit et sha2 parmi les dépendances directes d'apollia-core. Les trois étaient déjà des dépendances de l'espace de travail, et le fichier de verrouillage n'a gagné aucun paquet.

Ligne de commande​

Le CLI est en nom-verbe. Les opérations quotidiennes sont des verbes nus ; tout le reste est un nom portant des sous-commandes. --json est global et le terminal est détecté automatiquement, donc une même commande sert un humain et un script.

Les codes de sortie sont un contrat : 0 succès, 1 erreur d'usage, 2 erreur d'exécution, 3 tâche échouée, 4 délai dépassé, 5 annulé. Un appelant s'y branche sans analyser la sortie.

Application de bureau​

L'application de bureau est bâtie sur Tauri avec une interface Svelte, et partage le même runtime et le même interpréteur Python embarqué que le CLI. C'est une seconde façade au-dessus d'un seul runtime, pas une seconde implémentation.

Tout texte visible par l'utilisateur passe par la couche d'internationalisation, avec des entrées anglaises et françaises parallèles. Couleurs, espacements et typographie viennent des jetons de design ; une valeur codée en dur qui double un jeton est une valeur qui ne suivra pas un changement de thème.

Le runtime dans la fenêtre ou à côté​

Le runtime tourne à l'un de deux endroits : dans le processus du bureau, ou comme démon séparé, apollia-os start installé comme service de l'utilisateur, auquel le bureau se rattache. Le démon est ce qui laisse les agents et le travail planifié continuer fenêtre fermée, et c'est le même runtime, pas un second.

Le port TCP décide. Au lancement, avant l'interpréteur Python, toute base et tout écouteur, le bureau prend le port du runtime. L'obtenir veut dire qu'aucun runtime ne sert la machine : le bureau en démarre un dans son propre processus, sur cet écouteur même, qui n'est jamais relâché entre la décision et le service. Ne pas l'obtenir veut dire que quelque chose tient le port, et le bureau lui demande GET /api/v1/health, dont le corps porte un marqueur de produit et une version : un runtime Apollia compatible est rattaché, tout le reste est refusé. Une sonde suivie d'une liaison a été écartée, parce que l'instant entre les deux appartient au premier processus qui prend le port.

Refusé : deux runtimes sur une machine, le rattachement à un écouteur qui ne se dit pas Apollia ou à un runtime dont la version majeure ou mineure diffère, et un lancement qui s'arrête sans un mot. Un refus ouvre une fenêtre qui nomme ce qui tient le port et le geste à faire, et ajoute la raison à startup-refused.log dans le dossier de données.

Rattaché, le bureau ne construit aucun runtime à lui. Il joint le démon par l'API HTTP locale avec le jeton du dossier de données, et reçoit tout le bus d'événements en un flux, GET /api/v1/events. Il interroge la route de santé toutes les deux secondes plutôt que de supposer le démon présent, si bien qu'une sonde sans réponse montre aussitôt le runtime déconnecté. Une commande encore sans route répond une erreur typée qui la nomme, au lieu d'aller chercher un état que le processus n'a jamais construit ; scripts/check_attached_coverage.py compte les commandes routées face aux commandes enregistrées, sur un cliquet descendant. Une commande qui relève de la fenêtre plutôt que du runtime (quitter, redémarrer, chercher une mise à jour, lister les micros) n'a pas besoin de route : elle figure dans une liste à côté des commandes routées, avec la raison pour laquelle elle ne lit aucun état du runtime, et le même script refuse une entrée sans raison. Quitter le bureau rattaché laisse le démon servir.

Les deux modes sont livrés.

Une seule application, et un fichier de paquet qui l'ouvre​

L'extension .apo est associée à l'application de bureau (bundle.fileAssociations dans tauri.conf.json) : un paquet s'ouvre comme un document. Ouvrir un paquet ne l'installe jamais : quel que soit le geste qui apporte le fichier, l'application montre l'aperçu du paquet, et le paquet n'est installé ou mis à jour que depuis cet aperçu. Trois gestes y mènent : ouvrir le fichier depuis le système, le déposer dans la fenêtre, et le bouton Installer, qui ouvre un sélecteur de fichier.

Un fichier ouvert depuis le système doit atteindre l'application qui tourne déjà, pas en démarrer une seconde à côté. macOS garde de lui-même un seul processus par application et remet le fichier sous forme d'événement, au lancement comme ensuite. Windows et Linux démarrent un nouveau processus avec le chemin parmi ses arguments ; là, tauri-plugin-single-instance fait que ce second processus transmet ses arguments au premier et se termine, et le premier montre sa fenêtre. Le greffon est enregistré avant tout autre, dans les deux modes du runtime, et sous Windows et Linux seulement : sous macOS il ajouterait un second mécanisme là où le système en fournit déjà un. Le processus garde les chemins jusqu'à ce que l'interface les prenne, car un fichier qui démarre l'application arrive avant que l'interface soit chargée. Un fichier déposé dans la fenêtre passe par l'événement de glisser-déposer de la vue web, sans greffon.

Le coût est un greffon Tauri de plus à suivre, qui n'apporte aucune bibliothèque que l'arbre ne contenait déjà, et un chemin de code qui diffère entre macOS et les deux autres systèmes, donc à vérifier sur chacun. Sous Windows et Linux, un second lancement ne peut plus ouvrir une seconde fenêtre.

Refusés : un lien profond (apollia://install), qui ne répond pas au double-clic sur un fichier, et le bouton et le dépôt seuls, qui laissent un paquet incapable de s'ouvrir comme un document.

Distribution des agents​

Un agent voyage dans un paquet, un seul fichier .apo (voir Archive de paquet), et s'installe depuis ce fichier et rien d'autre : ouvert depuis le système, déposé sur la fenêtre, choisi avec le bouton Installer, ou passé à apollia-os package install. Quel que soit le geste, le paquet est d'abord prévisualisé, avec ce qu'il contient, qui l'a signé et ce que chacun de ses agents demande, et rien n'est écrit avant que l'utilisateur accepte. Un agent créé dans l'application en sort de la même façon : il est exporté en .apo, dont le manifeste est écrit depuis l'agent tel qu'il est, sans secret, sans mémoire et sans historique.

Publier les agents sur un index de paquets a été envisagé puis écarté : cela ferait de l'index une dépendance de disponibilité pour un produit dont la première promesse est de fonctionner sans aucune.

Les dépendances Python tierces déclarées par un paquet sont installées dans l'environnement virtuel propre à ce paquet, après consentement explicite, et l'utilisateur voit la liste avant que cela n'arrive.

Le coût est une étape pour un auteur : un dossier ou un dépôt ne s'installe pas tel quel. L'archive est d'abord construite (apollia-os package build), et c'est là que le manifeste est validé et que le paquet est signé.

Archive de paquet​

Un paquet d'agents voyage en un seul fichier .apo : une archive zip qui porte le manifeste apollia.toml à sa racine, les modules Python et les ressources du paquet à leurs chemins relatifs et, quand l'auteur l'a signé, un fichier SIGNATURE (voir Signatures) et un fichier SIGNATURE.key qui porte la clé publique qui l'a produit. La signature couvre toutes les autres entrées. La construction de l'archive valide le manifeste et refuse un dossier dont les modules définissent des outils ou des workflows que le manifeste ne décrit pas ; elle n'exécute aucun Python.

Le zip a été préféré au tar.gz : tout système d'exploitation l'ouvre sans outil, et son répertoire central permet de lire le manifeste sans décompresser le reste, ce dont un aperçu d'installation a besoin. Un tar.gz n'a pas d'index, lire un fichier y oblige à décompresser tout ce qui le précède, et il aurait coûté les deux mêmes dépendances directes (tar et flate2) sans rien de cela. Lire le zip avec un code maison a été écarté : un analyseur d'archives est une surface d'attaque, et une surface non auditée est pire qu'une surface maintenue.

Une archive vient d'ailleurs, sa lecture est donc bornée. Une entrée au chemin absolu, à composant .., à barre oblique inverse ou à préfixe de lecteur est refusée, comme un lien symbolique et un nom présent deux fois. L'archive est plafonnée à 256 Mio, 4096 entrées, 64 Mio par entrée décompressée et 512 Mio décompressés au total, le manifeste à 1 Mio. Les plafonds portent sur les octets réellement décompressés, pas sur les tailles que l'archive déclare : un en-tête menteur ne les lève pas. Un paquet au-delà d'un plafond est aussi refusé à la construction, si bien qu'Apollia lit tout ce qu'il construit.

Le coût est deux dépendances directes du moteur, zip et flate2, toutes deux déjà dans l'arbre par le programme de mise à jour du bureau ; aucune crate n'est entrée dans le fichier de verrouillage. zip est compilée avec deflate seul : ni chiffrement, ni bzip2, lzma, zstd ou deflate64. flate2 est déclarée à côté pour une seule raison, choisir l'implémentation en Rust pur (miniz_oxide) de ce deflate : zip laisse ce choix à l'appelant, et flate2 refuse de compiler sans implémentation.

L'export Excel d'un lot (voir Export d'un lot) a changé un point de ce choix. Son écrivain active la fonctionnalité deflate de zip, qui apporte deux crates en Rust pur, zopfli (Apache-2.0) et zlib-rs (Zlib), et fait de zlib-rs l'implémentation deflate de flate2 dans tout le programme. Les deux ont été acceptées plutôt qu'écrire le classeur à la main avec zip seul. Les bornes de lecture ci-dessus ne changent pas.

Les paquets qu'Apollia livre dans son propre binaire (le guide de l'application, l'onboarding et l'atelier d'agents) ne sont pas signés. Le bureau assemble chacun d'eux au démarrage à partir de fichiers embarqués à la compilation et l'installe par le même chemin que tout paquet. L'installation les reconnaît comme embarqués parce que le bureau le dit, jamais parce qu'une archive le dit : rien dans un fichier ne peut revendiquer cette origine, et la même archive remise d'ailleurs est un paquet non signé ordinaire. Un paquet embarqué s'installe sans avertissement sur sa signature, et une politique d'organisation qui exige des paquets signés ne le refuse pas. L'exemption est bornée à cette origine. Sa raison : le binaire qui porte le paquet est ce que couvre la signature de la distribution, si bien que signer le paquet une seconde fois ne prouverait rien de plus. Comme tout paquet, il est inscrit au journal d'audit, une version installée plus récente n'est jamais rétrogradée, et les surcharges de l'utilisateur comme l'état désactivé de ses agents survivent à une mise à jour.

Les paquets d'exemple suivent la même règle, à une différence près sur la suite. Ce sont des paquets déclaratifs embarqués dans le runtime à la compilation, depuis un dossier de l'arbre des sources. L'application les propose et n'en installe un que lorsque l'utilisateur le choisit, après l'aperçu que reçoit tout paquet. Un exemple est désigné au runtime par le nom de son paquet, jamais par un chemin, si bien que seul ce que porte le binaire est traité comme un exemple ; il n'est pas signé, et n'est ni signalé ni refusé pour cela, pour la raison ci-dessus. Une fois installé, il appartient à l'utilisateur : à la différence du guide, il se désinstalle comme tout paquet.

Un outil de paquet s'exécute avec un contexte nu : ni mémoire, ni profil de l'utilisateur. Une exception est décidée par l'hôte, jamais par un paquet. L'onboarding d'Apollia est livré comme un agent déclaratif doté d'un outil qui enregistre les faits de profil de la première conversation, le droit que l'agent d'onboarding tenait quand il exécutait du code. Le runtime accorde à cet outil le profil de l'utilisateur, en écriture, et la mémoire de l'onboarding, que le bureau lit pour savoir que l'onboarding est terminé. L'accord nomme le paquet, le module et la source que porte le binaire : un outil dont le module sur le disque n'est pas cette source octet pour octet, quel que soit le nom de son paquet, n'obtient rien.

Intégration hôte​

L'API HTTP versionnée est un contrat de pilotage, pas un détail d'implémentation. Elle porte un schéma OpenAPI généré et des clients TypeScript et Python générés, si bien qu'un produit hôte pilote le runtime sans avoir à rétro-concevoir des modules de routes.

Les ruptures passent par un nouveau préfixe de version, jamais par une mutation silencieuse de la version courante.

Surface d'erreur publique​

Toute énumération d'erreur publique de l'espace de travail porte #[non_exhaustive], et scripts/check_rust_rules.py en refuse une qui ne le porte pas. Ajouter une variante à un type d'erreur devient alors un changement ordinaire, plutôt qu'une rupture pour chaque crate qui filtre dessus, ce que l'attribut existe précisément pour absorber.

apollia-core est scellée comme les autres, et c'est la crate où l'attribut rapporte le plus : toutes les autres en dépendent, donc une variante non absorbée y casserait l'espace de travail entier d'un coup.

Le coût tombe du côté du filtrage, et seulement au franchissement d'une frontière de crate. Un match sur une de ces énumérations depuis une autre crate demande un bras de repli, et le compilateur cesse d'exiger que ce match soit exhaustif, donc une variante ajoutée plus tard atteint le repli au lieu de casser la compilation. Trois match inter-crates étaient exhaustifs quand l'attribut est arrivé, tous les trois traduisant une erreur du runtime en statut HTTP ; chacun répond désormais à une variante non filtrée par un 500 portant le message de l'erreur, plutôt que par une panique.

Plateformes et publication​

Linux, macOS et Windows sont des cibles supportées. Le confinement des outils diffère entre elles, comme dit plus haut.

Les versions sont publiées sur GitHub Releases. L'application de bureau consulte ce flux uniquement quand l'opérateur le demande, jamais en arrière-plan, et rapporte un flux vide comme un flux vide plutôt que comme une erreur.

Il y a un installeur par système. Aucun installeur n'existe en seconde édition pour une sorte de carte graphique : une édition par carte faisait choisir un téléchargement d'après un matériel que le lecteur ne connaît pas forcément, et liait la version d'un moteur à une version de l'application. Les moteurs qu'un installeur n'embarque pas, la compilation CUDA de llama-server et le Node.js qui fait tourner les serveurs d'outils, sont publiés à part, sur leur propre canal, sous un manifeste signé au format minisign avec une clé de distribution dédiée (voir Inférence locale). La chaîne qui les publie est distincte de celle qui publie l'application et se lance à la main : elle prend des archives amont épinglées par SHA-256, reconditionne chacune en zip, et signe le manifeste avec une clé qui ne signe rien d'autre, distincte de la clé des mises à jour de l'application. Le registre d'Apollia sert le manifeste et les archives, et GitHub Releases en est le miroir. Une application installée depuis une ancienne édition CUDA continue de se mettre à jour : le manifeste de mise à jour qu'elle demande est toujours publié, en copie du manifeste standard, si bien qu'elle passe à l'installeur unique de son système et reçoit le moteur CUDA par le canal des moteurs.

Vérification​

Au-delà de la suite de tests, deux classes de propriétés sont vérifiées par la machine plutôt que par la relecture : les entrelacements de concurrence des algorithmes d'acteurs, et les deux invariants cardinaux, le budget d'étapes non contournable et le verrou de bail de la boîte aux lettres, prouvés sous exécution symbolique bornée.

Elles tournent sur planification plutôt qu'à chaque changement, parce qu'elles sont lentes. Chacune a une contrepartie dans l'arbre qui tourne avec la suite de tests ordinaire, si bien qu'une régression apparaît au moment habituel et que le travail planifié la confirme.

Documentation​

Ce site est la documentation, organisé selon ce que le lecteur cherche à faire : des tutoriels pour apprendre, des guides pratiques pour accomplir, une référence pour consulter, des explications pour comprendre.

Les références de la ligne de commande et du SDK sont générées depuis le code puis commitées, avec un contrôle qui échoue quand les pages commitées dérivent de ce que le code produirait. Une référence qui peut dériver en silence est pire que pas de référence.