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>
20 KiB
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
- Issue : 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).
Décisions figées (one-way door)
- 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 viauuidv9+ (uuidv7()). Réversibilité : faible — un changement post-MVP imposerait une migration data lourde. timestamptzUTC partout. Postgres stocke en UTC interne, restitue selon laTimeZonede session ; on forceSET TIME ZONE 'UTC'côté pool et on n'écrit jamais detimestamp(sans tz). Évite les drifts d'horodatage entre nœuds. Réversibilité : faible.audit_eventsINSERT-only, rétention ≥ 1 an. Aucune route applicative ne faitUPDATEniDELETE; un job Phase 2 archivera/purgera au-delà de 1 an. La contrainte est appliquée par convention applicative + revoke des droitsUPDATE,DELETEsur le rôleagenthub_appau déploiement. Réversibilité : faible — la valeur de l'audit trail tient à son immuabilité.ON DELETE RESTRICTsur 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 ; unRESTRICTrend 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).ON DELETE CASCADEsur 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).ON DELETE SET NULLsuraudit_events.agent_id. L'audit doit survivre à la disparition de l'agent qu'il observe. Lepayload_hashreste 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.
iduuid v7 — clé sortable, exposée dans les URLs (/api/v1/agents/:id), passée dans les claims JWT (sub).nametext 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_nametext — libellé humain affiché en UI. Découplé denamepour permettre le rebranding sans casser les références.roletext CHECK enum — Phase 1 reconnaîtadmin(peut créer des agents, rooms, tokens) etagent(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) = simpleALTER TABLE … DROP CONSTRAINT … ADD CONSTRAINT …plutôt qu'unALTER TYPElent et parfois bloquant.created_at/updated_attimestamptz —updated_atbumpé 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 —
DELETEsuragentsest volontairement bloqué par les FKRESTRICTcôtémessagesetrooms.created_by. Phase 1 n'expose pas la suppression ; Phase 2 introduiradeleted_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_argon2idtext — 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.prefixtext 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.scopesjsonb — 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.statustext 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_attimestamptz 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_attimestamptz NULL — horodatage de révocation. CHECK assure la cohérence avecstatus='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 CASCADEsuragent_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).
slugtext UNIQUE — identifiant stable utilisé dans les URLs et les events (/api/v1/rooms/:slug). Slug-like pour rester portable (DNS, file-system, URL).nametext — libellé affiché en UI.created_byuuid REFERENCES agents — traçabilité de la création.RESTRICTen 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 roomsest un acte admin Phase 2 (EF hors-scope MVP) ; quand il arrivera,messages.room_idetroom_members.room_idcascadent 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_attimestamptz — horodatage de l'ajout (utile pour la UI "membre depuis").- Index
(agent_id)— la requêteagent:hello-ack(EF-2.4) liste les rooms d'un agent. La PK couvre déjà les requêtes pivot parroom_id(le préfixe d'un index composite est exploitable), mais pas le pivot paragent_idseul. - 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é.
iduuid v7 — sortable temporellement, donc pagination cursor-based stable (WHERE id < $cursor ORDER BY id DESC LIMIT 50). Évite le piège des cursors parcreated_aten cas d'égalité d'horodatage.room_iduuid NOT NULL — pivot de toutes les lectures.author_agent_iduuid NOT NULL REFERENCES agentsRESTRICT— protège l'historique en Phase 1 (cf. décision figée 4).bodytext — pas demediablobPhase 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_attimestamptz NOT NULL DEFAULT now() — autorité d'horloge = serveur. Les clients ne définissent pascreated_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 deid DESCen suffixe permet la pagination stable même si deux messages partagent exactement le mêmecreated_at(rare mais possible sous burst).idétant un UUID v7, son ordre est cohérent aveccreated_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_atmensuel = 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).
typetext 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_iduuid NULL — nullable parce que certains événements (ex. tentative de login sur un agent inexistant) n'ont pas d'agent.ON DELETE SET NULLparce que l'audit doit survivre à la disparition de son sujet.payload_hashbytea(32) — sha256 du payload original.BYTEAplutôt quetext(hex) pour économiser 50 % de stockage et permettre=direct sans encoding. CHECKlength = 32garantit qu'un hash mal formé est rejeté.tstimestamptz — horodatage serveur autoritaire. Pas decreated_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 lestoken-revokeddu dernier trimestre").(agent_id, ts) WHERE agent_id IS NOT NULL— partiel : la majorité des events ont unagent_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 wrapperpg.Poolqui force ça viaapplication_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 migration0001_init.sql(livrée en AGNHUB-6). Les altérations (ex. nouveautyped'audit) passent par une migration nommée explicitement. - Seed — un seed
db/seed.tscrée 3 agents test (alice,bob,cli) + 2 rooms (general,incidents) en local Docker (cf. plan J2). - Caractères / encoding —
databasecréée avecENCODING 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_casepartout (tables et colonnes). Drizzle est configuré pour mappercamelCaseTS ↔snake_caseSQL. - Compte applicatif —
agenthub_app(utilisé par le serveur Node) aSELECT, INSERT, UPDATE, DELETEsur toutes les tables saufaudit_events(SELECT, INSERTuniquement). Un compteagenthub_adminsé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_atsurmessages. 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 tablepresence. - Soft-delete d'agent / RGPD :
agents.deleted_atarrive Phase 2 + job de purge à 30 j (cf. plan §10). En attendant,RESTRICTcôté FK historique. - Archivage / partitioning : pas de partitioning de
messagesni d'audit_eventsau 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èqueuuid@9côté Node. Préférence : extension Postgres (cohérent avec lesDEFAULT 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".