Chapitres

DOC-05 / Référence technique · Chapitre 02

La couche données

Décrit l'architecture données de Synedre OS : une base PostgreSQL unique (un composant interne/un composant interne), les trois chemins d'accès (Nuxt/Nitro, Python agentique, Drizzle ORM DDL), les familles de tables par préfixe et les conventions de nommage associées.

La couche données de Synedre OS

Cette page décrit comment le harness agentique de Synedre OS lit et écrit ses données : une base PostgreSQL centrale, un adaptateur qui traduit les requêtes SQL côté interface web, des classes Python côté outillage agentique, un système de schéma-en-code pour les migrations, et les conventions de nommage qui font tenir l'ensemble. Elle est destinée aux ingénieurs qui reprennent le code.

1. La base de données centrale

Toute la donnée du vaisseau-mère vit dans un seul schéma PostgreSQL, hébergé dans un container dédié sur le VPS du vaisseau-mère. La base et le schéma portent des noms distincts — point qui surprend à la reprise : le container, la base et le schéma ont trois identifiants différents, et les tables du harness vivent dans le schéma spécifique, pas dans le schéma public.

Les credentials d'accès (utilisateur DB, mot de passe) sont portés par les fichiers d'environnement et ne sont jamais écrits en clair dans le code ; l'application lève une erreur explicite s'ils sont absents.

Frontière harness / multi-tenant. Ce chapitre documente exclusivement le schéma central du harness Synedre OS (cockpit agentique privé, mono-base). Les bases de données des tenants CodeMyShop utilisent le même adaptateur de connexion (voir §4–5) mais leurs données ne croisent jamais le schéma du harness : chaque tenant possède sa propre base, son propre schéma, ses propres tables. Le §4 couvre l'adaptateur parce qu'il sert aussi au cockpit — mais toute référence aux tenants y est contextuelle, non constitutive du harness.

Trois chemins d'accès

Deux chemins coexistent à l'exécution (lecture/écriture de lignes — DML) ; un troisième opère au niveau structurel (évolution du schéma — DDL) :

                   ┌──────────────────────────────────────┐
                   │         PostgreSQL — base centrale    │
                   │         schéma du harness Synedre OS  │
                   └───────────────┬──────────────────────┘
                                   │
   ┌──────────────────┬────────────┴──────────────┬─────────────────────┐
   │  DML — runtime   │  DML — outillage          │  DDL — structure    │
   │ (A) Interface    │ (B) Outillage Python       │ (C) Schéma-en-code  │
   │     web (Nuxt)   │     agentique              │     (Drizzle ORM)   │
   │  pool TCP        │  exécution via container   │  migrations SQL     │
   │  postgres-js     │  (fichier SQL temporaire   │  générées et        │
   │                  │   ou requête inline)        │  vérifiées (§6)     │
   └──────────────────┴────────────────────────────┴─────────────────────┘
  • Chemin A — l'interface web (Nuxt/Nitro) accède à la base via un pool de connexions TCP (postgres-js). Un utilitaire central expose la connexion ; un adaptateur spécifique gère la compatibilité avec les requêtes héritées (voir §4–5).
  • Chemin B — l'outillage Python agentique écrit des requêtes via un mécanisme d'exécution en container : les écritures passent par un fichier SQL temporaire copié puis exécuté ; les lectures utilisent une requête inline. Aucun chemin n'emploie de pipe stdin.
  • Chemin C — le DDL est géré en schéma-en-code (Drizzle ORM) : c'est lui qui fait foi pour l'évolution structurelle des tables. Il ne manipule pas de lignes ; il génère et applique des migrations SQL (détaillé en §6).

Instantané des familles de tables (31 mai 2026)

Famille Volume (tables de base) Périmètre
Tables legacy privées CodeMyShop / mothership 159 Dette à migrer — cockpit, agents, cicatrices…
Tables cockpit Synedre OS 69 Agents, chantiers, runs, négociation…
Tables PrestaShop natives 18 Dette historique — produits, catégories, traductions…
Tables PaaS public (tenants) ≈ 1 dans ce schéma Quasi absent du schéma harness ; vit surtout dans les DB tenants

S'y ajoutent 16 vues dans le schéma central (voir §3.4 — point critique : la vue des agents est une projection de la table cockpit correspondante).

Le décompte exact évolue à chaque chantier ; les chiffres ci-dessus sont l'instantané du 31 mai 2026. La requête de vérité canonique est documentée en §7.

2. Les familles de tables et leurs conventions

Le préfixe d'une table encode son périmètre et son régime de propriété. Quatre familles coexistent dans l'écosystème :

Famille Périmètre Statut
Tables PaaS public OSS Core communautaire — FAQ, blocs d'accueil, etc. Cible (dans les DB tenants)
Tables cockpit Synedre OS Agents, chantiers, runs, négociation, tâches atomiques Cible
Tables legacy privées CodeMyShop Ancien périmètre mothership et cockpit Dette à migrer
Tables PrestaShop natives Produits, catégories, traductions… Dette historique

Conventions transverses

  • Nom singulier — une table se nomme au singulier (aligné sur la convention PrestaShop natif).
  • Une table = une entité parente — pas de table fourre-tout.
  • Les fichiers source sont nommés en kebab-case ; les composants Vue en PascalCase.

Historique de migration. La famille de tables PaaS public résulte d'un rename unique effectué en v0.2.0, depuis l'ancien préfixe legacy (à l'époque où CodeMyShop était une extension PrestaShop). Dans le schéma du harness, la majorité des tables sont restées sous l'ancien préfixe — la dette n'a pas encore été soldée — tandis que le nouveau préfixe PaaS est appliqué dans les bases tenants OSS.

2.1 Cohabitation des familles sur une même entité logique

Point qui surprend à la reprise : une seule entité logique — le chantier — est répartie sur les deux familles legacy et cockpit.

Rôle de la table Famille Granularité
Le chantier lui-même Legacy privée 1 ligne par chantier
Les travaux granulaires Legacy privée N lignes par chantier
Les tâches atomiques Cockpit Synedre OS N lignes par travail
Satellites cockpit (équipe, verrou multi-session, QA…) Cockpit Synedre OS N lignes par chantier

Artefact d'historique. La table des tâches atomiques a été renommée lors de la migration vers la famille cockpit, mais sa séquence de clé primaire et la contrainte PRIMARY KEY conservent l'ancien nom de famille. Le rename a touché le nom de table, pas tous les objets dépendants — à corriger lors du prochain chantier de normalisation DDL.

Modèle de données des tables clés

La table des chantiers

La table centrale des chantiers repose sur une clé primaire sérielle. Ses colonnes notables sont les suivantes :

  • codename — identifiant kebab-case unique du chantier (varchar 64, obligatoire)
  • title — intitulé lisible (varchar 255, obligatoire)
  • client_id — référence optionnelle vers un client ; NULL indique un chantier interne
  • status — état courant, défaut 'planning'
  • priority — niveau de priorité, défaut 'P2'
  • current_focus, deadline, notes, mission_letter, preprod_test_plan — champs texte de pilotage
  • external_contacts — contacts tiers rattachés au chantier
  • ship_command — commande de livraison associée (varchar 255)
  • scope — périmètre contraint par un enum : synedre, codemyshop-oss, codemyshop-enterprise, tenant, business, juridique, negociation, conseil (ou NULL). Cet enum en base est plus large que celui documenté dans le fichier de référence racine, qui ne recense pas les valeurs juridique, negociation et conseil.
  • auto_explode — booléen, défaut true
  • mode_auto — booléen, défaut false
  • max_cost_eur — plafond budgétaire en euros
  • archived_at / archived_by — horodatage et auteur de l'archivage
  • date_add / date_upd — horodatages de création et mise à jour (timestamptz, défaut now())

Un trigger actif sur cette table se déclenche après toute mise à jour du champ status et propage la résolution vers le journal des e-mails entrants liés au chantier. Ce mécanisme de cascade garantit la cohérence entre l'état du chantier et les éléments de sa boîte de réception associée.

La table des tâches

Chaque chantier se décompose en tâches. La table des tâches est reliée au chantier via une clé étrangère logique vers la table des travaux. Colonnes clés :

  • title, status (défaut 'todo'), priority (défaut 'P2')
  • assignee_codename — codename de l'agent assigné (varchar 64)
  • estimated_tokens / actual_tokens / actual_cost_usd — métriques de consommation IA
  • recommended_model — modèle IA conseillé pour cette tâche (varchar 32)
  • position — ordre d'affichage
  • scope — périmètre contraint : synedre-internal, codemyshop-oss, codemyshop-enterprise, tenant-single, tenant-multi, infra, doctrine
  • visual_intent (text, nullable) — description de ce qui doit être visible à l'écran après le changement ; NULL indique une tâche non-visuelle. Ce champ alimente le moteur de vérification visuelle automatisée.
  • visual_url (text, nullable) — URL de vérification ; NULL renvoie vers le staging du chantier

Les colonnes visual_intent et visual_url ont été ajoutées via une migration idempotente (ADD COLUMN IF NOT EXISTS). Elles sont reconnues par la couche Python de gestion des entités mais pas encore reflétées dans le schéma TypeScript côté application web — cas de divergence volontaire entre la base de données live et le schéma applicatif, hors périmètre de l'outil d'audit de dérive automatique qui ne lit que le répertoire de schémas principal.

La table des tâches est également référencée par une table de dépendances inter-tâches, permettant de modéliser un graphe d'ordonnancement.

Le journal des apprentissages (cicatrices et victoires)

Ce journal enregistre les erreurs, leçons tirées et victoires de l'équipe d'agents. Chaque entrée porte :

  • agent_codename — codename de l'agent concerné (obligatoire)
  • error_type, description (obligatoire), root_cause, corrected_by
  • severity — niveau de gravité : low, medium, high, critical
  • kind — nature de l'entrée : 'failure' (défaut) ou 'victory' (créée via la compétence dédiée)
  • resolved — état de résolution (valeurs entières : 0, 1 ou 2)
  • tags — tableau de mots-clés (text[])
  • importance — score de 1 à 10
  • recall_count, learnable — indicateurs de réutilisabilité pédagogique

Ce journal est à usage interne : il ne comporte pas de colonne de langue et n'est pas exposé directement aux utilisateurs finaux.

Les agents : une vue de compatibilité, pas une table physique

Piège majeur lors d'une reprise. La table physique des agents est distincte de la vue exposée historiquement. La vue est un shim de rétrocompatibilité issu d'une migration de consolidation antérieure.

La table physique des agents contient les colonnes codename, nickname, role, group_name, active, des champs de configuration de poste (job_*), cognitive_frame et heritage. La vue exposée sous l'ancien nom est une simple projection SELECT … FROM de cette table physique. Toute lecture via l'ancien nom fonctionne de manière transparente ; en revanche, les écritures doivent cibler la table physique — les vues de ce type sont probablement en lecture seule. À vérifier avant tout UPDATE ou INSERT via l'ancien nom.

Le même mécanisme de vue-shim couvre une douzaine de paires similaires : les automates, les activités d'agents, les heartbeats, les relations entre agents, les XP et leur historique, les automates et leurs conduites, leurs logs, ainsi que les tables de pilotage intelligent. Au total, seize vues coexistent dans le schéma de base de données, réparties comme suit :

  • Douze vues-shim — redirection transparente de l'ancien espace de noms vers le nouveau
  • Une vue d'événements — liée au mécanisme de spawn Atlas
  • Trois vues analytiques SRE :
    • Agrégation quotidienne du journal des apprentissages sur 14 jours, ventilée par niveau de gravité et nature d'entrée
    • Synthèse des runs de revue sur 14 jours : nombre total, nombre de rollbacks, taux de rollback, nombre de verdicts bloquants, dernier run
    • Fréquence des avertissements sur 30 jours : dénombrement par code d'avertissement, nombre de verdicts bloquants associés, dernière occurrence

Vue de suivi d'erreurs non instanciée. Une vue d'unification des erreurs est référencée dans le manifeste du module de suivi d'erreurs et appelée par le code API, mais elle n'existe pas en base live : l'une de ses deux sources (les erreurs PostHog) n'est pas instanciée dans le schéma courant. Seule la source des erreurs serveur est présente. Une migration antérieure n'a donc pas pu recréer cette vue. Le décompte réel est bien 16 vues, et non 17.


Accès à la base de données en runtime — multi-tenant

Résolution du client et sélection de l'adaptateur

Le module d'accès à la base de données expose une fonction principale qui, à partir d'une requête HTTP entrante, identifie le tenant concerné puis renvoie un adaptateur de connexion prêt à l'emploi.

La résolution du tenant suit une cascade de trois règles, dans l'ordre de priorité :

  1. Un identifiant de client défini explicitement dans la configuration runtime du processus (chaque VPS client peut le définir statiquement).
  2. Une correspondance entre le hostname de la requête et une table de mapping tenant→base de données, permettant de servir plusieurs tenants depuis une seule instance applicative.
  3. Un repli sur l'identifiant interne du vaisseau-mère (ac-hub).

Une fois le tenant identifié, une seconde fonction détermine si la connexion PostgreSQL est activée pour ce tenant. Deux listes blanches coexistent : les tenants internes au vaisseau-mère (liste figée dans le code) et les tenants PostgreSQL activés dynamiquement via une variable d'environnement. Si le tenant n'appartient à aucune des deux listes, la fonction lève une exception — le chemin MySQL a été définitivement supprimé lors d'une phase de migration antérieure.

Verrou global PostgreSQL

Point de vigilance critique. Un verrou global, contrôlé par la variable d'environnement PG_ENABLED_DOMAINS, conditionne l'accès PostgreSQL pour tous les tenants sans exception. Si cette variable ne contient pas le wildcard *, même un tenant figurant dans la liste blanche interne sera rejeté. En production, le basculement global est en place (*) et le verrou est ouvert. Mais vider cette variable constitue un rollback complet vers MariaDB pour l'ensemble du système — opération à manier avec la plus grande prudence.

Ce mécanisme est un héritage de la stratégie de migration progressive : dans les premières phases, seuls certains modules étaient activés sur PostgreSQL (PG_ENABLED_DOMAINS=cms,inventory) ; le basculement global (=*) n'est intervenu qu'à la phase de coupure définitive.

Table de mapping tenant → base de données

La table de mapping est construite dynamiquement à partir de variables d'environnement suivant la convention NUXT_TENANT_DB_<CODENAME_EN_MAJUSCULES> (les tirets du codename étant remplacés par des underscores). La valeur de chaque variable encode, dans l'ordre : le nom de la base, l'hôte, le port, l'utilisateur et le mot de passe — séparés par des virgules. Une fonction utilitaire expose la liste des codenames tenant connus, utilisée notamment pour propager des secrets globaux vers chaque base tenant.

La convention « un tenant = un codename canonique » s'applique à une quinzaine de surfaces du système (configuration, routing, base de données, logs…). La fonction de résolution du client en est l'une des surfaces clés.


L'adaptateur PostgreSQL

L'adaptateur PostgreSQL expose une interface query / get / run identique à l'ancienne interface MySQL, permettant une substitution transparente. Il s'appuie sur la bibliothèque postgres-js et assure à la volée la conversion du SQL MySQL hérité.

Le pool de connexions

Un singleton de connexion est instancié avec les paramètres suivants (lus depuis les variables d'environnement) :

  • Hôte, port, utilisateur et nom de base — avec des valeurs par défaut internes si les variables sont absentes
  • Mot de passe de la base — obligatoire : l'adaptateur lève une exception immédiate si cette variable est absente
  • Pool : 20 connexions maximum, timeout d'inactivité 60 s, durée de vie maximale 1 800 s, timeout de connexion 15 s

Toutes les requêtes sont exécutées dans le schéma PostgreSQL dédié au vaisseau-mère.

La couche de traduction SQL

Chaque requête SQL est soumise à un pipeline de réécriture avant exécution. Les transformations sont appliquées dans l'ordre suivant :

# Transformation Détail
1 Backticks → guillemets doubles Conversion des délimiteurs d'identifiants MySQL vers la syntaxe PostgreSQL
2 Qualification par schéma Les tables préfixées par les espaces de noms internes sont automatiquement préfixées du nom de schéma PostgreSQL après les mots-clés FROM, JOIN, INTO, UPDATE, TABLE
2b/2c DATE_SUB / DATE_ADD Réécriture en arithmétique d'intervalles PostgreSQL, pour les unités DAY, MONTH, YEAR, HOUR, MINUTE, SECOND, sur littéraux comme sur placeholders
2d TIMESTAMPDIFF Converti en FLOOR(EXTRACT(EPOCH FROM …) / diviseur) selon l'unité demandée
3 IFNULLCOALESCE PostgreSQL ne connaît pas IFNULL
4 INSERT IGNOREON CONFLICT DO NOTHING Sans effet si ON CONFLICT est déjà présent
5 Auto-quotation des alias camelCase AS fooBarAS "fooBar" pour préserver la casse (PostgreSQL met en minuscules les identifiants non quotés). Exception : les types natifs PostgreSQL sont laissés tels quels pour ne pas interférer avec les expressions CAST(x AS TEXT)
6 Placeholders ?$1, $2, … Conversion positionnelle par un parseur caractère par caractère qui ignore les ? présents à l'intérieur de chaînes quotées

Sécurité des paramètres. La transformation des placeholders ne fait que renommer les marqueurs dans le texte SQL. Le binding réel des valeurs est délégué à postgres-js via son mécanisme natif de requêtes paramétrées — aucune valeur n'est interpolée dans la chaîne SQL. Le tableau de paramètres est transmis tel quel, sans modification. Il n'y a donc aucun risque d'injection SQL sur les paramètres liés.

Cas non gérés automatiquement (le code appelant doit les traiter manuellement) : ON DUPLICATE KEY UPDATE, LAST_INSERT_ID(), GROUP_CONCAT, FIND_IN_SET, DATE_FORMAT, CURDATE(). Lorsqu'un endpoint en a besoin, une branche conditionnelle dédiée est ajoutée côté appelant.

Interface et émulation de l'insertId MySQL

L'interface de l'adaptateur expose trois méthodes :

  • query<T>(sql, params?) — retourne un tableau de résultats typés
  • get<T>(sql, params?) — retourne le premier résultat ou null
  • run(sql, params?) — retourne un objet { affectedRows, insertId } émulant le comportement MySQL

La méthode run() émule l'insertId MySQL : pour un INSERT sans clause RETURNING ni ON CONFLICT, elle ajoute automatiquement RETURNING id_<entité> en suivant la convention de nommage des clés primaires. Cette logique est désactivée pour les tables à clé primaire composite (une liste explicite dans le code) ainsi que pour toutes les tables suffixées _lang ou _shop, qui ne disposent pas d'une colonne id_<table> unique.

Drizzle ORM — schéma-as-code pour la structure des tables

Les chemins décrits dans les sections précédentes (accès TypeScript côté Nuxt, accès Python côté outillage) font du DML : ils lisent et écrivent des lignes contre une structure de table supposée déjà en place. Aucun des deux ne crée ni n'altère de table. La structure (DDL — CREATE TABLE, colonnes, types, index, contraintes) est régie par un troisième chemin : Drizzle ORM, utilisé en mode schema-as-code. Les schémas TypeScript sont la source de vérité déclarée de la structure des tables ; le SQL CREATE/ALTER est généré (ou rédigé) puis appliqué à la base de données, jamais l'inverse.

Le dialecte était MariaDB avant la migration vers PostgreSQL effective lors d'un chantier de découplage antérieur. Les dépendances concernées sont drizzle-orm et drizzle-kit dans leurs versions courantes.

Configuration de Drizzle

Le fichier de configuration à la racine du dépôt définit les paramètres suivants :

Clé Valeur Note
dialect 'postgresql' Remplace l'ancien dialecte MariaDB
schema Trois globs couvrant le cœur OSS, les modules cockpit et les packs enterprise Emplacements des déclarations TypeScript
out Dossier de migrations SQL générées, dans le cœur OSS Destination des fichiers .sql produits
schemaFilter Schéma applicatif dédié Restreint l'introspection/diff à notre schéma, ignore public et les schémas natifs
Credentials DB Portés par variables d'environnement (hôte, port, utilisateur, mot de passe DB, nom de base) Jamais écrits en clair dans le dépôt
strict / verbose true / true Confirmation avant push, sortie détaillée

⚠️ Le port par défaut configuré ici cible l'exposition TCP de la base de données sur la machine hôte (mapping host), et non le port interne du container. drizzle-kit est conçu pour pointer une base exposée en TCP, pas pour accéder directement au processus interne du container.

Commandes drizzle-kit

Aucun script npm dédié n'est exposé dans le manifeste du projet. Les commandes se lancent directement via npx :

Commande Effet
npx drizzle-kit generate Compare les schémas TS à l'état connu et écrit une nouvelle migration SQL dans le dossier de sortie
npx drizzle-kit migrate Applique les migrations en attente à la base ciblée par les credentials
npx drizzle-kit introspect Reverse-engineer une base existante vers des fichiers TypeScript (utile pour adopter une table legacy non encore déclarée)

Les schémas TypeScript

Chaque fichier déclare une ou plusieurs tables via l'API pgSchema(...).table(...) de Drizzle. Les schémas sont distribués en trois périmètres correspondant aux trois globs de configuration :

Périmètre Contenu Audité par le détecteur de dérive
Cœur OSS + tables boutique Tables historiques de la plateforme e-commerce (environ 85 fichiers, dont des re-exports) ✅ Oui
Modules cockpit vaisseau-mère Agents, chantiers, travaux, invoicing, drill… (environ 47 fichiers) ❌ Non
Packs enterprise Extensions métier avancées (environ 14 fichiers) ❌ Non

Point d'attention : un fichier de référence de typage définit la table du registre de modules, mais il est hors des trois globs actifs — il sert d'illustration et de référence de typage, pas comme entrée active du DDL Drizzle. Les tables cockpit sont déclarées dans les modules (périmètre 2) ; leurs migrations transitent par un système séparé (voir ci-dessous), pas par le DDL Drizzle.

Le détecteur de dérive ne lit que le périmètre cœur OSS. Les colonnes déclarées dans les modules cockpit ou les packs enterprise ne déclenchent pas de drift bloquant — c'est pourquoi des colonnes ajoutées récemment peuvent coexister dans la base et dans la couche entité sans être présentes dans le schéma Drizzle du module.

Ce que les schémas TypeScript typent : nom physique de colonne, type PostgreSQL, notNull, default/defaultNow, primaryKey (simple ou composite), unique, index. Les types métier sont raffinés via $type<...>() (typage TS sans contrainte DB). Exemple de déclaration pour une table de registre de modules :

export const appSchema = pgSchema('nom_du_schema_applicatif')

export type Runtime      = 'ps' | 'nuxt'
export type ModuleStatus = 'active' | 'disabled' | 'deprecated'

export const moduleRegistryTable = appSchema.table('nom_table_registre', {
  idModuleRegistry: serial('id_module_registry').primaryKey(),
  codename: varchar('codename', { length: 128 }).notNull().unique(),
  version:  varchar('version',  { length: 32 }).notNull(),
  runtime:  varchar('runtime', { length: 4 }).$type<Runtime>().notNull().default('ps'),
  status:   varchar('status',  { length: 10 }).$type<ModuleStatus>().notNull().default('active'),
  manifestJson: text('manifest_json').$type<ModuleManifest | null>(),
  // … hash de schéma, date de dernière migration, dateAdd, dateUpd
}, (t) => ({
  kRuntimeStatus: index('idx_runtime_status').on(t.runtime, t.status)
}))

export type RegistryRow    = typeof moduleRegistryTable.$inferSelect
export type RegistryInsert = typeof moduleRegistryTable.$inferInsert

Points à retenir :

  • Les ENUM MariaDB ont été portés en varchar(N) + $type<Union>() : la contrainte est au niveau TypeScript, pas sous forme de type ENUM PostgreSQL natif.
  • Les tables multilingues à clé primaire composite se traduisent en primaryKey({ columns: [t.idFaq, t.idLang] }).
  • Les types $inferSelect/$inferInsert exportés donnent les types de ligne consommables côté code. Le runtime Nuxt passe par son propre adaptateur (accès DML direct), pas par le query-builder Drizzle — ici Drizzle ne sert qu'au DDL et au typage.

Les migrations générées

Le dossier de migrations du cœur OSS contient, à date, trois migrations idempotentes : ajout de colonnes logistiques, import mapping, et ajout d'une colonne téléphone sur la table client pour l'inscription B2B/C. Le fichier journal de Drizzle (meta/_journal.json) présente une liste d'entrées vide.

⚠️ Fait structurant : le tracking d'application automatique de drizzle-kit migrate n'est pas la voie opérante. Les fichiers SQL du dossier de sortie sont écrits idempotents à la main (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS) et appliqués manuellement sur chaque base de données cliente. En pratique, Drizzle et les schémas TypeScript constituent la source de vérité déclarative ; l'application réelle reste une opération manuelle par base, jamais auto-propagée.

Deuxième voie de migrations (tables cockpit) : les tables du cockpit vaisseau-mère — déclarées dans les modules, hors périmètre du détecteur de dérive — utilisent un système de migrations SQL manuelles séparé, réparti sur deux dossiers :

  • Un dossier dédié au schéma du vaisseau-mère (synedre.com, Odyssée, documentation, routage IA…). Les fichiers sont nommés par date et sujet, tous idempotents.
  • Un dossier racine pour les migrations appliquées directement à la base locale (tables cockpit hors vaisseau-mère). Ce dossier accueille aussi des sous-dossiers d'archivage (applied/, _applied/) qui tracent les migrations déjà exécutées sur la cible principale.

L'application est manuelle dans les deux cas — ces migrations ne transitent pas par le mécanisme d'application automatique de dérive décrit ci-après.

Le détecteur de dérive TS ↔ base live

Comme l'application des migrations est manuelle et multi-tenant, un ALTER appliqué sur une base mais oublié sur une autre partirait en production silencieusement. Un script d'audit (environ 315 lignes) ferme ce trou.

Fonctionnement :

  • La fonction de parsing lit les fichiers TypeScript du périmètre cœur OSS et en extrait, par regex, la liste des tables et de leurs colonnes.
  • La fonction d'interrogation de base lit information_schema.columns pour le schéma applicatif concerné, restreinte aux tables du périmètre cœur, sur chaque base cible.
  • Le diff par base cible distingue : bloquant = table ou colonne déclarée en TS mais absente de la base (migration manquante) ; info = présente en base mais pas en TS.
  • Les faux positifs sont filtrés par un fichier d'exclusions : tables exclusives au vaisseau-mère à ignorer sur les bases clientes, et tables boutique OSS sorties de la base centrale lors d'un chantier de découplage antérieur.

Codes de sortie : 0 — pas de dérive bloquante ; 1 — dérive bloquante détectée ; 2 — erreur d'exécution.

L'audit s'utilise sur une base seule (mode par défaut, rétrocompatible), sur un tenant nommé, sur l'ensemble des tenants, ou avec affichage verbeux.

Gate de déploiement : l'audit est câblé de façon bloquante dans le pipeline de déploiement. Avant de pousser un artefact, le pipeline vérifie que la base du tenant cible dispose bien de toutes les colonnes attendues par le code. En cas de dérive, le déploiement s'arrête. Il s'agit d'un check pré-déploiement ; il n'existe pas de cron périodique dédié.

L'applicateur automatique de dérive DDL

Un second script (environ 553 lignes) complète l'audit : là où l'audit signale la dérive, ce script la corrige.

Principes fondamentaux :

  • Jamais de DROP (ni table, ni colonne). On n'ajoute que ce qui manque.
  • Idempotent : rejouable sans effet si la base est déjà à jour.
  • Transaction unique avec arrêt sur erreur : rollback total si une instruction échoue.
  • Génère uniquement des ADD COLUMN IF NOT EXISTS et CREATE TABLE IF NOT EXISTS, avec types, contraintes NOT NULL/DEFAULT et checks dérivés des schémas TypeScript.

Le script réutilise les définitions de l'auditeur (liste des cibles, parsers de schémas, chargement des exclusions) pour maintenir une source de vérité unique.

Pipeline interne :

  1. Parsing des schémas TypeScript en structures TableDef/ColumnDef (types PG, nullable, default, check).
  2. Calcul de la dérive TS ↔ base live : tables absentes → CREATE TABLE ; colonnes absentes → ADD COLUMN.
  3. Génération du bloc SQL idempotent.
  4. Exécution en transaction sur la base cible (uniquement si le flag --apply est passé).
Mode d'usage Effet
Dry-run sur un tenant Affiche le SQL sans l'exécuter (comportement par défaut)
Apply sur un tenant Applique le SQL en transaction sur la base cible
Dry-run sur tous les tenants Inspecte l'ensemble des bases sans rien modifier

Codes de sortie : 0 — pas de dérive (ou application réussie) ; 1 — dérive détectée en dry-run, ou erreur lors de l'application ; 2 — erreur de parsing ou de connexion.

L'applicateur est câblé dans le chemin de déploiement unifié, conditionné par un flag d'activation (DRIFT_AUTO_APPLY, désactivé par défaut — gate explicite). Depuis la consolidation des scripts de déploiement lors d'un chantier récent, tous les tenants empruntent ce chemin unifié ; les anciens scripts de déploiement per-tenant ont été supprimés, fermant le gap historique où certains tenants ne bénéficiaient pas de la correction automatique.

Qui fait foi pour l'évolution structurelle

Fichiers schéma TypeScript       ← SOURCE DE VÉRITÉ déclarative du DDL
   │  (1) on édite le TS
   ▼
Migration SQL idempotente
   │  (2a) Application manuelle par base impactée
   │  (2b) Applicateur automatique de dérive (flag DRIFT_AUTO_APPLY=1)
   ▼
Base PostgreSQL live (vaisseau-mère + bases clientes)
   ▲
   └─ (3) Détecteur de dérive : vérifie TS == live, bloque le déploiement si écart

Règle de reprise : pour faire évoluer une table du périmètre cœur, on édite d'abord le schéma TypeScript, on génère ou rédige la migration idempotente, on l'applique sur chaque base (voie manuelle ou automatique), puis on relance l'audit — le pipeline de déploiement le rejouera de toute façon. Ne jamais modifier la base live sans répercuter le schéma TypeScript : l'audit le signalerait comme dérive bloquante au prochain déploiement.

Inversement, les classes Entity Python utilisées par l'outillage ne créent jamais de colonne : leur liste de champs est une whitelist de colonnes supposées déjà présentes en base, pas une déclaration de structure.

Accès agentique (Python) — le pattern Entity

Le code Python du vaisseau-mère n'accède pas à la base de données via un pool de connexions réseau comme le fait le runtime frontend. Il passe par un mécanisme de sous-processus qui délègue l'exécution SQL au container de base de données. Une classe de base commune centralise ce transport et expose un CRUD générique dont héritent une quarantaine de classes métier spécialisées.

Transport bas niveau

Trois helpers de la classe de base couvrent les cas d'usage courants :

Helper Usage Notes
Écriture sécurisée INSERT / UPDATE / DELETE Le SQL est écrit dans un fichier temporaire à nom unique (PID + UUID pour éviter les collisions multi-processus), copié dans le container, puis exécuté avec arrêt immédiat sur erreur (ON_ERROR_STOP=1). Aucun pipe stdin n'est utilisé.
Lecture tabulaire SELECT sur colonnes simples SQL transmis en ligne de commande, résultat séparé par tabulations. À éviter sur les colonnes TEXT contenant des sauts de ligne.
Lecture CSV SELECT sur colonnes TEXT multi-lignes Mode CSV (RFC 4180) ; les valeurs NULL sont restituées comme chaîne vide.

Chaque requête préfixe automatiquement la session avec le schéma applicatif approprié. Les paramètres de connexion (container cible, nom de base, utilisateur, mot de passe DB, schéma) sont lus depuis les variables d'environnement du processus — aucun identifiant n'est codé en dur.

La classe Entity — CRUD générique

La classe de base expose un CRUD paramétré par trois attributs de classe :

  • Nom de table — la table physique ciblée.
  • Clé primaire — nom de la colonne PK, utilisé dans les clauses RETURNING et WHERE.
  • Liste blanche de champs — seules les colonnes déclarées ici sont acceptées en INSERT/UPDATE ; toute colonne absente de la liste est silencieusement ignorée.

Le comportement standard de chaque méthode :

  • Création : validation métier → injection automatique de client_id canonique si la colonne est dans la liste blanche → filtrage sur la liste blanche → INSERT … RETURNING <pk> avec horodatage NOW() sur date_add et date_upd.
  • Mise à jour : filtrage sur la liste blanche + mise à jour de date_upd.
  • Recherche / existence / suppression : implémentations standard.

L'échappement SQL interne gère nativement les booléens (TRUE/FALSE), les dictionnaires et les listes (sérialisés en JSON).

Les sous-classes surchargent la méthode validate(data, mode) pour y placer les règles métier : les violations bloquantes lèvent une ValidationError ; les avertissements non bloquants sont collectés et retournés à l'appelant sans interrompre l'opération.

Création atomique d'un chantier avec son squelette

Pour interdire les chantiers orphelins (sans travail ni tâche), la méthode de création du composant Chantier crée en une seule transaction BEGIN … COMMIT :

  1. Le chantier lui-même.
  2. Un premier travail rattaché au chantier (lookup par codename).
  3. Une ou plusieurs tâches initiales rattachées au travail (lookup par identifiant de travail).

L'option ON_ERROR_STOP=1 garantit le rollback complet si l'une quelconque des insertions échoue.

Validations bloquantes

  • Le codename doit respecter le format kebab-case (4 à 64 caractères) et être unique.
  • La priorité doit appartenir à l'ensemble {P0, P1, P2, P3}.
  • Chaque tâche doit avoir un titre et un assigné non vides ; l'assigné doit exister dans le référentiel des agents.
  • Recrutement multi-agents : pour tout chantier dont le périmètre concerne un tenant client, au moins deux agents distincts doivent être assignés — règle issue de la doctrine de supervision croisée.

Comportements automatiques

  • Les champs priority, description, estimated_tokens, estimated_h, position, recommended_model, scope, visual_intent et visual_url sont propagés en transparence depuis le chantier vers chaque tâche.
  • Si recommended_model est absent d'une tâche, il est calculé automatiquement par l'heuristique de sélection de modèle (voir ci-dessous).

Entité Tâche — estimation, modèle recommandé, compétences et outils

Estimation automatique

Lors de la création d'une tâche, si le volume de tokens n'est pas fourni, un estimateur dédié est appelé automatiquement. Un avertissement non bloquant est émis si l'estimation reste manquante.

Heuristique de sélection du modèle IA

La méthode de recommandation de modèle suit une cascade de critères :

  • Modèle le plus puissant si la priorité est P0, ou si la tâche a échoué de façon récurrente (≥ 2 itérations en échec), ou si le volume estimé dépasse 8 000 tokens.
  • Modèle intermédiaire si le volume est compris entre 1 500 et 8 000 tokens.
  • Modèle léger pour les tâches en dessous de 1 500 tokens sans facteur aggravant.

Attachement de compétences et d'outils

Deux méthodes permettent de qualifier une tâche :

  • Compétence : recherche dans le référentiel de compétences par nom (clé naturelle) → insertion dans la table de liaison avec clause ON CONFLICT … DO NOTHING. Si la compétence est inconnue, une proposition est enregistrée en statut pending et la méthode retourne False.
  • Outil : même logique via le référentiel d'outils (recherche par slug). Outil inconnu → proposition pending + retour False.

Cascades de statut

Les mises à jour de statut sur les tâches et les travaux déclenchent des cascades automatiques vers les niveaux supérieurs :

Tâche → done / cancelled
  (condition : toutes les tâches du travail sont dans un état terminal,
               dont au moins une en done)
  └─ Cascade vers le Travail
       ├─ Une équipe QA est recrutée ?
       │    └─ Oui → le verdict QA est appliqué (run de validation)
       └─ Non → Travail passe à done

Travail → done / cancelled
  (condition : tous les travaux du chantier sont terminaux,
               dont au moins un en done)
  └─ Cascade vers le Chantier
       ├─ Garde-fou discovery-only :
       │    tous les travaux terminés en phase discovery
       │    → explosion automatique (un agent LLM génère les travaux d'implémentation)
       │    au lieu de promouvoir le chantier
       └─ Sinon → Chantier passe en statut test (préprod, en attente de revue)
            └─ Avertissement non bloquant si le plan de test préprod
               ou la commande de livraison sont manquants

Résolution automatique de travaux bloqués

Un travail correctif peut être lié à un travail parent en pause. Quand le travail correctif passe à done, le travail parent est automatiquement fermé : son statut passe de paused à done, ses tâches restantes passent à cancelled, et la décision est consignée dans le journal d'audit du travail parent.

Conventions d'internationalisation et polymorphisme

Tables de traduction (_lang)

Tout texte visible par un visiteur vit dans une table sœur suffixée _lang, jamais dans la table parente.

  • Le suffixe est exactement _lang — pas _translation, _i18n ni _locale.
  • La clé primaire est composite : (id_<entité>, id_lang), sans auto-incrément. En contexte multi-boutique, la PK devient (id_<entité>, id_lang, id_shop) — aucune table _shop_lang séparée n'est créée.
  • Séparation stricte des responsabilités : la table parente porte les clés étrangères, flags, dates et énumérations ; la table _lang porte les champs textuels (title, description, meta_* et tout texte orienté visiteur).

Cette convention a un impact direct sur l'adaptateur de requêtes : les tables _lang sont exclues de l'heuristique de récupération de clé primaire car elles ne possèdent pas de PK simple. Côté frontend, toutes les chaînes passent par la fonction de traduction ; aucune chaîne UI ne doit être codée en dur dans le code source.

Polymorphisme par parent_type / parent_id

Quand une fonctionnalité s'applique à plusieurs types d'entités parentes, une seule table polymorphique est créée — jamais une table par type de parent. La colonne parent_type porte un discriminant textuel ('cms', 'category', 'product'…) et parent_id l'identifiant dans la table correspondante.

Exception documentée : l'extension 1:1 d'une entité native adopte le pattern de table extra (PK = FK vers l'entité native), sans polymorphisme. Les tables de liaison pure N:N sont nommées en ordre alphabétique des deux entités, sans suffixe _asso ni _link, et sans table _lang associée.

Pas de JSON métier en colonne

Les colonnes de contenu JSON (payload_json, content_i18n, labels_json…) sont interdites pour du contenu métier structuré. La seule tolérance concerne les payloads techniques éphémères (webhooks, logs, état de session) documentés explicitement par un commentaire de colonne.

Dans le cockpit agentique, les colonnes JSON sont réservées à l'audit-trail technique en mode append-only (contexte de décision, découvertes, historique d'itération) — c'est la tolérance, pas la règle générale. Un audit de schéma automatisé (boucle nocturne planifiée) détecte et signale les violations en priorité P0.

Pièges courants à l'intégration

  1. Vues-shim vs tables physiques : plusieurs noms d'entités exposés dans l'API sont en réalité des vues qui relisent des tables physiques sous un autre nom. En lecture, la vue suffit ; en écriture, il faut cibler la table physique sous-jacente. Ce pattern concerne une douzaine de paires (agents, activité d'agent, heartbeat, relations, XP, historique XP, automates, agents d'automate, conduites, logs d'automate, logs de règles intelligentes).
  2. Container ≠ base ≠ schéma : le container de base de données, le nom logique de la base et le schéma applicatif sont trois niveaux distincts. Les confondre produit des erreurs de résolution silencieuses.
  3. Tenant non migré PostgreSQL : l'adaptateur de requêtes lève une erreur immédiate pour tout tenant non déclaré dans la liste des tenants PostgreSQL activés. Le chemin de secours MySQL a été supprimé.
  4. Énumération scope : la contrainte CHECK en base de données est l'unique référence de vérité pour les valeurs autorisées du champ scope — elle peut être plus large que ce qu'indique la documentation écrite.
  5. Résidu de renommage : la table des tâches a conservé ses séquences et contraintes nommées selon l'ancien préfixe, résidu d'un renommage partiel. Ce n'est pas une erreur — c'est un état connu.
  6. SQL MySQL legacy : un convertisseur automatique transforme les requêtes MySQL vers PostgreSQL, mais plusieurs constructions ne sont pas couvertes (GROUP_CONCAT, DATE_FORMAT, ON DUPLICATE KEY UPDATE…). Ces cas échouent silencieusement et nécessitent une branche PostgreSQL native écrite manuellement.