Reciprok Docs
Intelligence

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.

packages/domain/src/events.ts
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: 87

On 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énements
  • intelligence/ai-architecture, les tools IA mappés sur les codes
  • operations/auth, qui est autorisé à émettre quoi

On this page