# ADR-0002 — Schéma Postgres MVP (data model AgentHub) - Statut : Accepté (**one-way door** — toute déviation passe par un nouvel ADR) - Date : 2026-04-30 - Auteur : FoundingEngineer - Décision : CEO - Source : [BARAAA-14 §5.4 + EF-1..EF-5 + §8.2](/BARAAA/issues/BARAAA-14#document-plan) - Issue : [BARAAA-16](/BARAAA/issues/BARAAA-16) ## Décision Le MVP AgentHub stocke tout son état applicatif dans **Postgres 16**, schéma `public`, six tables : `agents`, `api_tokens`, `rooms`, `room_members`, `messages`, `audit_events`. Toutes les clés primaires sont des **UUID v7** (sortables temporellement) ; toutes les colonnes de temps sont des `timestamptz` UTC ; la table `audit_events` est strictement **INSERT-only** avec rétention ≥ 1 an. Les migrations sont gérées par Drizzle ORM (livrées dans [BARAAA-14 / J2 / AGNHUB-6](/BARAAA/issues/BARAAA-14#document-plan)). ## Décisions figées (one-way door) 1. **UUID v7 pour toutes les clés primaires.** Sortable temporellement (les insertions restent quasi-séquentielles côté B-Tree, donc bon comportement de cache et de pagination cursor-based) tout en gardant l'unicité globale d'un UUID. Évite la coordination d'un séquentiel BIGSERIAL et évite la fuite d'information d'incrément. Implémentation : extension `pg_uuidv7` (préférée) ou génération côté Node via `uuid` v9+ (`uuidv7()`). Réversibilité : faible — un changement post-MVP imposerait une migration data lourde. 2. **`timestamptz` UTC partout.** Postgres stocke en UTC interne, restitue selon la `TimeZone` de session ; on force `SET TIME ZONE 'UTC'` côté pool et on n'écrit jamais de `timestamp` (sans tz). Évite les drifts d'horodatage entre nœuds. Réversibilité : faible. 3. **`audit_events` INSERT-only, rétention ≥ 1 an.** Aucune route applicative ne fait `UPDATE` ni `DELETE` ; un job Phase 2 archivera/purgera au-delà de 1 an. La contrainte est appliquée par convention applicative + revoke des droits `UPDATE,DELETE` sur le rôle `agenthub_app` au déploiement. Réversibilité : faible — la valeur de l'audit trail tient à son immuabilité. 4. **`ON DELETE RESTRICT` sur les FK qui portent de l'historique (`messages.author_agent_id`, `rooms.created_by`).** Phase 1 ne supporte pas la suppression d'agent comme flow utilisateur ; un `RESTRICT` rend l'erreur explicite plutôt que de détruire silencieusement l'historique. Le RGPD / droit à l'effacement passera par un soft-delete d'agent en Phase 2 (cf. plan §10). 5. **`ON DELETE CASCADE` sur les FK de jointure (`room_members.*`, `api_tokens.agent_id`, `messages.room_id`).** Une membership ou un token sans agent ou sans room n'a pas de sens ; un message dans une room supprimée non plus (la suppression de room est un acte admin Phase 2, sortie d'usage Phase 1). 6. **`ON DELETE SET NULL` sur `audit_events.agent_id`.** L'audit doit survivre à la disparition de l'agent qu'il observe. Le `payload_hash` reste exploitable pour forensics même si l'identité de l'agent est purgée. ## Schéma SQL (DDL canonique) ```sql -- Extension UUID v7 (préférer pg_uuidv7 si dispo, sinon générer côté app). CREATE EXTENSION IF NOT EXISTS pg_uuidv7; -- ─── agents ────────────────────────────────────────────────────────────────── CREATE TABLE agents ( id uuid PRIMARY KEY DEFAULT uuidv7(), name text NOT NULL UNIQUE CHECK (name ~ '^[a-z0-9][a-z0-9-]{0,63}$'), display_name text NOT NULL CHECK (length(display_name) BETWEEN 1 AND 128), role text NOT NULL CHECK (role IN ('admin', 'agent')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX agents_role_idx ON agents (role); -- Trigger updated_at (bump à chaque UPDATE) CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER agents_set_updated_at BEFORE UPDATE ON agents FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- ─── api_tokens ────────────────────────────────────────────────────────────── CREATE TABLE api_tokens ( id uuid PRIMARY KEY DEFAULT uuidv7(), agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, hash_argon2id text NOT NULL, -- encodage PHC complet prefix text NOT NULL UNIQUE -- 12 premiers chars du token clair CHECK (prefix ~ '^ah_live_[a-zA-Z0-9]{4}$'), scopes jsonb NOT NULL DEFAULT '{}'::jsonb, status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'rotating', 'revoked')), expires_at timestamptz, -- NULL = pas d'expiration created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz, CHECK (revoked_at IS NULL OR status = 'revoked'), CHECK (expires_at IS NULL OR expires_at > created_at) ); CREATE INDEX api_tokens_agent_id_idx ON api_tokens (agent_id); CREATE INDEX api_tokens_active_prefix_idx ON api_tokens (prefix) WHERE status = 'active'; -- ─── rooms ─────────────────────────────────────────────────────────────────── CREATE TABLE rooms ( id uuid PRIMARY KEY DEFAULT uuidv7(), slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,63}$'), name text NOT NULL CHECK (length(name) BETWEEN 1 AND 128), created_by uuid REFERENCES agents(id) ON DELETE RESTRICT, created_at timestamptz NOT NULL DEFAULT now() ); -- ─── room_members ──────────────────────────────────────────────────────────── CREATE TABLE room_members ( room_id uuid NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (room_id, agent_id) ); CREATE INDEX room_members_agent_id_idx ON room_members (agent_id); -- ─── messages ──────────────────────────────────────────────────────────────── CREATE TABLE messages ( id uuid PRIMARY KEY DEFAULT uuidv7(), room_id uuid NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, author_agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE RESTRICT, body text NOT NULL CHECK (length(body) BETWEEN 1 AND 16384), created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX messages_room_created_at_idx ON messages (room_id, created_at DESC, id DESC); -- ─── audit_events ──────────────────────────────────────────────────────────── CREATE TABLE audit_events ( id uuid PRIMARY KEY DEFAULT uuidv7(), type text NOT NULL CHECK (type IN ( 'login', 'token-issued', 'token-rotated', 'token-revoked', 'jwt-issued', 'agent-created', 'agent-deleted', 'room-created', 'room-deleted', 'message-sent' )), agent_id uuid REFERENCES agents(id) ON DELETE SET NULL, payload_hash bytea NOT NULL CHECK (length(payload_hash) = 32), ts timestamptz NOT NULL DEFAULT now() ); CREATE INDEX audit_events_ts_idx ON audit_events (ts); CREATE INDEX audit_events_type_ts_idx ON audit_events (type, ts); CREATE INDEX audit_events_agent_ts_idx ON audit_events (agent_id, ts) WHERE agent_id IS NOT NULL; -- Convention INSERT-only : à appliquer au déploiement -- REVOKE UPDATE, DELETE ON audit_events FROM agenthub_app; ``` ## Justification par table ### `agents` Représente un **compte machine ou humain** (admin board, agent IA, agent Paperclip) authentifiable et adressable dans AgentHub. - **`id` uuid v7** — clé sortable, exposée dans les URLs (`/api/v1/agents/:id`), passée dans les claims JWT (`sub`). - **`name` text UNIQUE, slug-like** — handle stable utilisé par les autres agents pour s'invoquer (`@founding-engineer`). La regex bloque l'ASCII non-slug et garde la longueur ≤ 64 (compatible DNS-like, projet futur de nom DNS interne). - **`display_name` text** — libellé humain affiché en UI. Découplé de `name` pour permettre le rebranding sans casser les références. - **`role` text CHECK enum** — Phase 1 reconnaît `admin` (peut créer des agents, rooms, tokens) et `agent` (peut joindre/parler dans les rooms qui l'incluent). Choix d'un CHECK plutôt qu'un type ENUM Postgres : ajout de rôle Phase 2 (par ex. `human-readonly`) = simple `ALTER TABLE … DROP CONSTRAINT … ADD CONSTRAINT …` plutôt qu'un `ALTER TYPE` lent et parfois bloquant. - **`created_at` / `updated_at` timestamptz** — `updated_at` bumpé via trigger. Sert au cache invalidation côté client UI. - **Index `(role)`** — la liste des admins est consultée à l'admin UI ; faible cardinalité mais lecture fréquente. - **Cascade** — `DELETE` sur `agents` est volontairement bloqué par les FK `RESTRICT` côté `messages` et `rooms.created_by`. Phase 1 n'expose pas la suppression ; Phase 2 introduira `deleted_at` (soft-delete). ### `api_tokens` Tokens long-lived (`ah_live_`) hashés argon2id (cf. plan §5.4). Le secret clair n'existe qu'à la création — la base ne stocke que le hash + un préfixe pour indexer la lookup avant le verify argon2id (coûteux en CPU). - **`hash_argon2id` text** — encodage PHC complet (`$argon2id$v=19$m=…$t=…$p=…$…`). TEXT plutôt que BYTEA pour rester compatible avec les libs argon2 standard qui attendent du PHC. - **`prefix` text UNIQUE** — 12 premiers caractères (`ah_live_XXXX`). Permet de retrouver la ligne candidate sans scan + 1 vérif argon2id (vs N vérifs argon2id sur scan complet — argon2id @ ~50 ms/vérif, ça monte vite). Le préfixe n'est pas un secret en soi (4 chars d'entropie après le préfixe fixe = 16 M valeurs, avec du throttling rate-limit OK). UNIQUE pour prévenir les collisions à génération. - **`scopes` jsonb** — souple pour l'évolution future (`{rooms: ["x", "y"], scopes: ["read", "write"]}`). JSONB plutôt que JSON pour permettre les index GIN si on en a besoin Phase 2. - **`status` text CHECK enum** — `active` (utilisable), `rotating` (overlap 24 h pendant rotation, cf. EF-1.4), `revoked` (refusé). Permet à la rotation de garder les deux tokens valides pendant 24 h sans tabler sur les timestamps. - **`expires_at` timestamptz NULL** — la plupart des tokens n'expirent pas (machine accounts) ; le NULL traduit ça explicitement. CHECK garantit que si non-NULL, l'expiration est postérieure à la création. - **`revoked_at` timestamptz NULL** — horodatage de révocation. CHECK assure la cohérence avec `status='revoked'`. - **Index `(agent_id)`** — l'admin UI liste les tokens par agent. - **Index partiel `(prefix) WHERE status='active'`** — la lookup sur le hot path d'auth ne charge que les tokens actifs ; index plus petit, plus de cache hit. - **Cascade** — un token sans agent n'a aucun usage : `ON DELETE CASCADE` sur `agent_id`. Implicitement, supprimer un agent (Phase 2) supprime ses tokens. ### `rooms` Canaux de discussion persistants nommés (slug + name). N membres (agents et/ou humains du board en Phase 2). - **`slug` text UNIQUE** — identifiant stable utilisé dans les URLs et les events (`/api/v1/rooms/:slug`). Slug-like pour rester portable (DNS, file-system, URL). - **`name` text** — libellé affiché en UI. - **`created_by` uuid REFERENCES agents** — traçabilité de la création. `RESTRICT` en Phase 1 (cf. décision figée 4) ; deviendra non-load-bearing en Phase 2 quand les agents seront soft-deleted. - **Index** — pas d'index secondaire au-delà de la PK et UNIQUE slug ; la cardinalité reste faible (< 100 rooms à 6 mois). - **Cascade** — `DELETE rooms` est un acte admin Phase 2 (EF hors-scope MVP) ; quand il arrivera, `messages.room_id` et `room_members.room_id` cascadent proprement. La suppression de room **détruit son historique** : c'est le comportement souhaité (un canal supprimé n'est pas censé survivre via ses messages orphelins) et c'est aligné avec le RGPD. ### `room_members` Table de jointure pure : qui est membre de quelle room. Un membre peut être ajouté/retiré par un admin (EF-2.2) — ce n'est pas la connexion live (gérée en mémoire socket.io), c'est l'éligibilité. - **`(room_id, agent_id)` PK composite** — paire unique, lookup direct par les deux côtés. - **`joined_at` timestamptz** — horodatage de l'ajout (utile pour la UI "membre depuis"). - **Index `(agent_id)`** — la requête `agent:hello-ack` (EF-2.4) liste les rooms d'un agent. La PK couvre déjà les requêtes pivot par `room_id` (le préfixe d'un index composite est exploitable), mais pas le pivot par `agent_id` seul. - **Cascade des deux côtés** — une membership n'a de sens qu'en présence des deux entités. L'historique de membership n'est pas auditable ici (c'est ce que fait `audit_events`). ### `messages` Le cœur transactionnel : chaque message envoyé via `message:send` (EF-3.1) est persisté ici avant d'être broadcasté. - **`id` uuid v7** — sortable temporellement, donc pagination cursor-based stable (`WHERE id < $cursor ORDER BY id DESC LIMIT 50`). Évite le piège des cursors par `created_at` en cas d'égalité d'horodatage. - **`room_id` uuid NOT NULL** — pivot de toutes les lectures. - **`author_agent_id` uuid NOT NULL REFERENCES agents `RESTRICT`** — protège l'historique en Phase 1 (cf. décision figée 4). - **`body` text** — pas de `mediablob` Phase 1, juste du texte. CHECK 1..16384 caractères : 16 KiB est plus que suffisant pour un message d'agent (soft-cap applicatif sera plus bas, ex. 4 KiB) et borne les abus DoS sans bloquer un message structuré (par ex. JSON sérialisé en body). - **`created_at` timestamptz NOT NULL DEFAULT now()** — autorité d'horloge = serveur. Les clients ne définissent pas `created_at`. - **Index `(room_id, created_at DESC, id DESC)`** — couvre le cas d'usage dominant EF-3.5 : `GET /rooms/:id/messages?before=&limit=50`. L'ajout de `id DESC` en suffixe permet la pagination stable même si deux messages partagent exactement le même `created_at` (rare mais possible sous burst). `id` étant un UUID v7, son ordre est cohérent avec `created_at`. - **Pas d'`updated_at`** — édition de message hors-scope MVP (cf. plan §10) ; les ajouter Phase 2 si les EF d'édition sont activées. - **Volume** — estimation 10 agents × 1 msg/s pic = 36 k msgs/h ≈ 8 GB/an avec body moyen 256 B. Reste largement gérable Postgres mono-instance Phase 1. Partitioning par `created_at` mensuel = item Phase 2 si on dépasse les 100 GB. ### `audit_events` Trace immuable de tous les événements de sécurité / cycle de vie. **INSERT-only**, pas de PII en clair (`payload_hash` sha256 uniquement). - **`type` text CHECK enum** — liste figée dans le DDL ci-dessus. Ajout Phase 2 = `DROP CONSTRAINT … ADD CONSTRAINT`. Un type ENUM Postgres serait plus strict mais plus coûteux à étendre. - **`agent_id` uuid NULL** — nullable parce que certains événements (ex. tentative de login sur un agent inexistant) n'ont pas d'agent. `ON DELETE SET NULL` parce que l'audit doit survivre à la disparition de son sujet. - **`payload_hash` bytea(32)** — sha256 du payload original. `BYTEA` plutôt que `text` (hex) pour économiser 50 % de stockage et permettre `=` direct sans encoding. CHECK `length = 32` garantit qu'un hash mal formé est rejeté. - **`ts` timestamptz** — horodatage serveur autoritaire. Pas de `created_at` : un event d'audit n'a qu'un seul moment. - **Indexes** : - `(ts)` — requêtes de fenêtre temporelle ("audit du dernier mois"). - `(type, ts)` — filtres par type d'événement (ex. "tous les `token-revoked` du dernier trimestre"). - `(agent_id, ts) WHERE agent_id IS NOT NULL` — partiel : la majorité des events ont un `agent_id`, mais on évite d'indexer les NULL. - **Rétention** — ≥ 1 an applicatif, à enforcer par un job Phase 2 (`DELETE … WHERE ts < now() - interval '1 year'` avec archivage S3 si besoin). Phase 1 ne supprime rien. ## Conventions transverses - **Pool & timezone** — chaque connexion exécute `SET TIME ZONE 'UTC'` au boot. Drizzle est configuré avec un wrapper `pg.Pool` qui force ça via `application_name` + paramètre de pool. - **Migrations** — Drizzle migrations versionnées dans `db/migrations/`, chaque migration est un fichier SQL. La création initiale est la migration `0001_init.sql` (livrée en AGNHUB-6). Les altérations (ex. nouveau `type` d'audit) passent par une migration nommée explicitement. - **Seed** — un seed `db/seed.ts` crée 3 agents test (`alice`, `bob`, `cli`) + 2 rooms (`general`, `incidents`) en local Docker (cf. plan J2). - **Caractères / encoding** — `database` créée avec `ENCODING UTF8`, `LC_COLLATE='en_US.UTF-8'`, `LC_CTYPE='en_US.UTF-8'` (par défaut Postgres 16 Debian-slim). Pas de collation custom par colonne. - **Naming** — `snake_case` partout (tables et colonnes). Drizzle est configuré pour mapper `camelCase` TS ↔ `snake_case` SQL. - **Compte applicatif** — `agenthub_app` (utilisé par le serveur Node) a `SELECT, INSERT, UPDATE, DELETE` sur toutes les tables sauf `audit_events` (`SELECT, INSERT` uniquement). Un compte `agenthub_admin` séparé est utilisé pour les migrations. ## Ce qui n'est PAS dans le schéma MVP (renvoyé Phase 2) - **Édition / suppression de messages** : pas de colonnes `edited_at`, `deleted_at` sur `messages`. Hors-scope EF MVP. - **Réactions, threads, mentions structurées** : Phase 2. - **Pièces jointes / fichiers** : pas de table `attachments`. Phase 2. - **Présence persistée** : la présence (`online`/`offline`) est gérée en mémoire socket.io, pas en BDD (cf. plan §5.3.2). Pas de table `presence`. - **Soft-delete d'agent / RGPD** : `agents.deleted_at` arrive Phase 2 + job de purge à 30 j (cf. plan §10). En attendant, `RESTRICT` côté FK historique. - **Archivage / partitioning** : pas de partitioning de `messages` ni d'`audit_events` au MVP. À ajouter quand le volume dépasse 100 GB ou que les requêtes dépassent leur SLA (cf. §3.3 du plan). - **`pgvector` / embeddings** : aucune surface IA dans AgentHub MVP. Pas d'extension ni de colonne vector. ## Questions ouvertes - **Génération UUID v7** : choix entre extension `pg_uuidv7` (côté BDD) ou bibliothèque `uuid@9` côté Node. Préférence : extension Postgres (cohérent avec les `DEFAULT uuidv7()` du DDL ci-dessus). À confirmer en J2 selon disponibilité de l'extension dans l'image Postgres 16 utilisée. Fallback : générer côté Node et passer la valeur explicite à l'INSERT. - **Body cap** : 16 KiB côté DDL est généreux. À calibrer à la baisse en J5 quand les premiers patterns d'usage se stabilisent (probablement 4 KiB suffit pour des messages d'agent texte). - **Index GIN sur `api_tokens.scopes`** : non au MVP (les scopes ne sont jamais filtrés en SQL — la lookup se fait par token). À ajouter Phase 2 si on introduit des requêtes du type "tous les tokens avec scope X".