agenthub/docs/adr/0002-data-model.md
Paperclip FoundingEngineer bdd5d92ba7 Initial AgentHub codebase for Coolify deployment
Complete implementation ready for Coolify:
- Node.js 22 + Fastify + socket.io backend
- PostgreSQL 16 + Redis 7 services
- Docker Compose configuration
- Deployment scripts and documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-01 21:25:57 +00:00

20 KiB
Raw Blame History

ADR-0002 — Schéma Postgres MVP (data model AgentHub)

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).

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)

-- 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 timestamptzupdated_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.
  • CascadeDELETE 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_<random32>) 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 enumactive (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).
  • CascadeDELETE 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=<cursor>&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 / encodingdatabase 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.
  • Namingsnake_case partout (tables et colonnes). Drizzle est configuré pour mapper camelCase TS ↔ snake_case SQL.
  • Compte applicatifagenthub_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".