Domain events
Codes d'action déterministes typés Zod, le langage commun entre l'IA, l'API et la timeline
L'IA et le frontend déclenchent des actions métier. La timeline les enregistre. Pour que les trois ne dérivent pas, on les encode tous avec le même set de codes typés.
Le problème
Sans codes structurés :
- L'IA décrit ses actions en langage naturel ("J'ai ajouté #102 au catalogue")
- Le frontend log un autre format ("ADD_MEMBER")
- La timeline log un troisième ("memberAdded")
- Les analyses sont impossibles, le typage n'existe pas, l'intent IA n'est pas vérifiable
Le principe
Un enum exhaustif de codes d'action, chacun avec un schéma Zod (ou TypeBox) qui décrit son payload. Tout ce qui se passe dans Reciprok peut être exprimé comme un de ces codes.
import { z } from 'zod';
export const DomainEventCode = {
// Members
MEMBER_CREATED: 'member.created',
MEMBER_UPDATED: 'member.updated',
MEMBER_DEACTIVATED: 'member.deactivated',
MEMBER_TAG_ADDED: 'member.tag_added',
MEMBER_PHOTO_UPLOADED: 'member.photo_uploaded',
MEMBER_KB_ENTRY_ADDED: 'member.kb_entry_added',
MEMBER_AVAILABILITY_SET: 'member.availability_set',
// Requests
REQUEST_CREATED: 'request.created',
REQUEST_FIELD_UPDATED: 'request.field_updated',
REQUEST_STATUS_ADVANCED: 'request.status_advanced',
REQUEST_QUALIFICATION_SENT: 'request.qualification_sent',
REQUEST_QUALIFICATION_RECEIVED: 'request.qualification_received',
// Results / catalog pipeline
MEMBER_ADDED_TO_RESULTS: 'request.member_added_to_results',
MEMBER_REMOVED_FROM_RESULTS: 'request.member_removed_from_results',
MEMBER_ADVANCED_IN_PIPELINE: 'request.member_advanced',
CATALOG_COMPOSED: 'catalog.composed',
CATALOG_SENT: 'catalog.sent',
CATALOG_VIEWED: 'catalog.viewed',
// Communications
EMAIL_DRAFTED: 'email.drafted',
EMAIL_SENT: 'email.sent',
EMAIL_RECEIVED: 'email.received',
WHATSAPP_SENT: 'whatsapp.sent',
// Commission
WINNER_SELECTED: 'request.winner_selected',
COMMISSION_CREATED: 'commission.created',
COMMISSION_INVOICED: 'commission.invoiced',
COMMISSION_PAID: 'commission.paid',
// AI
AI_SUGGESTION_PROPOSED: 'ai.suggestion_proposed',
AI_SUGGESTION_APPROVED: 'ai.suggestion_approved',
AI_SUGGESTION_REJECTED: 'ai.suggestion_rejected',
AI_TOOL_EXECUTED: 'ai.tool_executed',
AI_CONTEXT_INJECTED: 'ai.context_injected',
} as const;
export type DomainEventCode = (typeof DomainEventCode)[keyof typeof DomainEventCode];Schémas typés
Chaque code a un schéma Zod qui décrit la structure de son payload. Pas de payload "libre", tout est validé.
export const DomainEventPayloads = {
'member.created': z.object({
memberId: z.string().uuid(),
code: z.string(),
name: z.string(),
createdBy: z.enum(['user', 'ai', 'system']),
}),
'request.member_added_to_results': z.object({
requestId: z.string().uuid(),
memberId: z.string().uuid(),
memberCode: z.string(),
stage: z.enum(['search_result', 'catalog_recommended', 'catalog_compatible']),
aiScore: z.number().min(0).max(1).optional(),
aiExplanation: z.string().optional(),
addedBy: z.enum(['user', 'ai']),
}),
'request.field_updated': z.object({
requestId: z.string().uuid(),
field: z.enum(['budget', 'participantsCount', 'dates', 'formats', 'description', 'area']),
previousValue: z.unknown(),
nextValue: z.unknown(),
reason: z.string().optional(),
}),
'catalog.sent': z.object({
requestId: z.string().uuid(),
catalogId: z.string().uuid(),
recipientEmail: z.string().email(),
withWhatsapp: z.boolean(),
memberCount: z.number(),
}),
'ai.suggestion_proposed': z.object({
conversationId: z.string().uuid(),
messageId: z.string().uuid(),
toolName: z.string(),
toolInput: z.record(z.unknown()),
requiresConfirmation: z.boolean(),
}),
// … etc, un schéma par code
} as const satisfies Record<DomainEventCode, z.ZodType>;
export type DomainEventPayload<C extends DomainEventCode> =
z.infer<(typeof DomainEventPayloads)[C]>;L'événement complet
export const DomainEventSchema = z.object({
id: z.string().uuid(),
code: z.enum(Object.values(DomainEventCode) as [string, ...string[]]),
payload: z.unknown(), // validé contre DomainEventPayloads[code]
actor: z.enum(['user', 'ai', 'system', 'member', 'organizer']),
actorId: z.string().nullable(),
requestId: z.string().uuid().nullable(),
traceId: z.string(),
occurredAt: z.date(),
});C'est exactement la structure de la table timeline_event (voir data/timeline-events), la table est l'append log de tous ces événements.
Helper d'émission typé
Une seule fonction emit() pour ne jamais oublier d'enregistrer un événement, avec inférence automatique du payload selon le code.
import { db } from '@reciprok/db';
import { timelineEvent } from '@reciprok/db/schema';
export async function emit<C extends DomainEventCode>(
code: C,
payload: DomainEventPayload<C>,
ctx: { actor: 'user' | 'ai' | 'system' | 'member' | 'organizer'; actorId?: string; requestId?: string; traceId: string },
) {
// Validation au runtime, garantit que le payload matche le schéma
const validated = DomainEventPayloads[code].parse(payload);
await db.insert(timelineEvent).values({
code,
payload: validated,
actor: ctx.actor,
actorId: ctx.actorId ?? null,
requestId: ctx.requestId ?? null,
traceId: ctx.traceId,
occurredAt: new Date(),
});
// Optionnel : push une notification si le code est notifiable
if (NOTIFIABLE_CODES.includes(code)) {
await pushNotification(code, validated);
}
}Usage :
await emit('request.member_added_to_results', {
requestId: req.id,
memberId: member.id,
memberCode: member.code,
stage: 'catalog_recommended',
aiScore: 0.87,
aiExplanation: 'Capacité 200 cocktail, ambiance industrielle, 8ème arrondissement',
addedBy: 'ai',
}, { actor: 'ai', requestId: req.id, traceId: ctx.traceId });Si on se trompe sur la forme du payload, TypeScript et Zod refusent à la compilation et au runtime.
Lien avec les tools IA
Les tools exposés à Claude (voir intelligence/ai-architecture) sont mappés 1-pour-1 sur les codes d'action. Quand Claude propose un tool_use, on sait exactement quel code il va déclencher.
// packages/ai/src/tools.ts
export const TOOL_TO_EVENT: Record<string, DomainEventCode> = {
add_member_to_results: 'request.member_added_to_results',
remove_member_from_results: 'request.member_removed_from_results',
advance_member_in_pipeline: 'request.member_advanced',
update_request_field: 'request.field_updated',
send_email: 'email.sent',
compose_catalog: 'catalog.composed',
};L'exécuteur de tool emit le bon code après l'action :
async function executeTool(toolUse: ToolUse, ctx: AiContext) {
const code = TOOL_TO_EVENT[toolUse.name];
if (!code) throw new Error(`Unknown tool: ${toolUse.name}`);
// 1. Valider l'input du tool selon le schéma du payload
const payload = DomainEventPayloads[code].parse(toolUse.input);
// 2. Exécuter l'action métier (qui peut elle-même appeler emit)
const result = await runToolHandler(toolUse.name, payload, ctx);
// 3. Si le handler n'a pas emit lui-même, on le fait ici
return result;
}Pourquoi c'est important
1. La timeline devient queryable
SELECT actor, COUNT(*)
FROM timeline_event
WHERE code = 'request.member_added_to_results'
AND occurred_at > now() - interval '30 days'
GROUP BY actor;
-- ai: 412
-- user: 87On peut mesurer précisément ce que fait l'IA vs les humains, par catégorie d'action.
2. L'IA est explicable
Chaque action IA est un événement typé avec sa raison (aiExplanation). Le frontend peut afficher "Pourquoi cette suggestion ?" en lisant directement la timeline.
3. Les permissions sont uniformes
Une seule table de permissions par code d'événement :
const PERMISSIONS = {
'request.winner_selected': ['team.member', 'team.admin'],
'commission.invoiced': ['team.admin'],
'request.field_updated': ['team.member', 'team.admin', 'ai'],
// …
};L'IA n'a pas de permission magique, elle est listée comme un acteur autorisé pour certains codes, refusée pour d'autres (ex: commission.invoiced ne peut JAMAIS être déclenché par l'IA).
4. Replay et tests
Les tests rejouent une séquence de domain events pour reconstituer l'état d'une demande. Pas besoin de mocker l'API, on emit les événements et on vérifie l'état final.
test('un catalogue gagné crée une commission', async () => {
await emit('request.created', { ... }, ctx);
await emit('request.member_added_to_results', { ... }, ctx);
await emit('catalog.composed', { ... }, ctx);
await emit('catalog.sent', { ... }, ctx);
await emit('request.winner_selected', { ... }, ctx);
const commissions = await db.query.commission.findMany({ where: ... });
expect(commissions).toHaveLength(1);
});5. Audit RGPD
L'audit log de qui a fait quoi, quand, pourquoi, est gratuit : c'est la timeline elle-même. Pour répondre à un droit d'accès RGPD, on filtre par actorId et on a tout.
Codes notifiables
Certains événements génèrent automatiquement une Notification pour l'équipe :
const NOTIFIABLE_CODES: DomainEventCode[] = [
'request.created',
'request.qualification_received',
'catalog.viewed',
'ai.suggestion_proposed', // si requires_confirmation
'commission.paid',
];Le mapping est central, pas dispersé dans 30 fichiers.
Évolutions
- Versioning : si le schéma d'un payload évolue, on suffixe le code (
catalog.sent.v2) plutôt que de muter le schéma. Les anciens événements restent lisibles. - Génération auto de la doc API : à terme, on peut générer une page Fumadocs listant tous les codes et leurs schémas Zod.
- Webhooks externes : un domain event peut être pushé vers des webhooks externes (ex: notifier un CRM client). La structure est déjà un format propre à exposer.
Voir aussi
data/timeline-events, la table qui stocke ces événementsintelligence/ai-architecture, les tools IA mappés sur les codesoperations/auth, qui est autorisé à émettre quoi