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>
353 lines
20 KiB
Markdown
353 lines
20 KiB
Markdown
# 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_<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 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=<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 / 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".
|