Règle de validation, hook ou flow ? Où une règle métier doit vivre dans une app écrite par l’AI : un CRM, sept règles, trois maisons
Sept règles métier sur un CRM, trois maisons pour chacune. Le choix est une table : un prédicat est une règle de validation, réécrire avant sauvegarde est un hook, tout le reste est un flow, qui se relit comme des données.
La réponse d’abord. Une règle métier dans une app ObjectStack a trois maisons possibles, et le choix est une table, pas une affaire de goût. Une règle qui se décide à partir du seul enregistrement en cours d’écriture, sans I/O, est une règle de validation sur l’objet. Une règle qui doit modifier l’enregistrement en attente avant qu’il soit sauvegardé est un hook before. Tout le reste — effets de bord, approbations, tout ce qui se met en pause, tout ce qui tourne à l’horloge — est un flow, et la documentation fait du flow le défaut : « un flow est la surface par défaut pour la logique applicative ; un hook est le filet de sécurité pour ce qu’un graphe de nœuds ne peut pas exprimer ». La raison est structurelle, et c’est exactement ce qu’un relecteur vérifie réellement : un flow se relit comme des données, un hook se relit comme du code. Le CRM livré dans le dépôt ObjectStack déclare sept règles de ce genre — cinq validations, un hook, un flow — et chacune est là où la table le dit. Ci-dessous : la table, les sept règles, une transcription REST où chaque maison refuse ou réécrit une écriture, et une coquille plantée dans deux maisons pour montrer les deux forces de relecture. Tout mesuré sur la @objectstack/cli 18.2.0 publiée, la version qu’installe npm create objectstack@latest.
La phrase que reçoit un agent
Un product owner écrit une ligne dans un document de cadrage : les remises de plus de 40 % nécessitent une approbation. Un agent à qui l’on demande de l’implémenter peut mettre cette phrase à trois endroits, et les trois se parsent, se construisent et démarrent :
- comme règle de validation sur
crm_opportunity— un prédicat CEL qui refuse l’écriture ; - comme hook — un handler
beforeInsert/beforeUpdatequi inspectectx.inputet lève une erreur, ou fixe un champ en silence ; - comme flow — un déclencheur de changement d’enregistrement qui ouvre une demande d’approbation et gare l’enregistrement jusqu’à ce que quelqu’un décide.
Une seule est juste pour chaque forme de règle, et « les remises de plus de 40 % nécessitent une approbation » est en fait deux règles habillées d’une seule phrase. « Plus de 40 % n’est pas autorisé sans exception » est un prédicat sur l’enregistrement — une règle de validation. « Quelqu’un doit approuver l’exception » se met en pause et attend une personne — un flow. Un hook ne fait bien ni l’un ni l’autre : il tourne en ligne, donc ne peut pas se mettre en pause, et s’il refuse l’écriture, il la refuse comme du code que personne ne peut comparer champ par champ.
L’exemple de CRM livré dans le monorepo ObjectStack a déjà fait ce choix. Sa règle discount_cap est une validation, dans l’objet, sous forme de données :
// examples/app-crm/src/objects/opportunity.object.ts (@objectstack/cli@18.2.0)
validations: [
{
type: 'script' as const,
name: 'discount_cap',
label: 'Discount Cap 40%',
description: 'Discounts over 40% require special approval.',
condition: P`record.discount_percent != null && record.discount_percent > 40`,
message: 'Discount cannot exceed 40% without an approved exception.',
severity: 'error' as const,
},
Voilà la première maison. Les deux autres sont sur le même objet, et le reste de cet article consiste à les distinguer.
La décision, sous forme de table
Trois passages de la documentation tranchent la question, et aucun ne dit « ça dépend ».
La documentation de validation définit ce qu’est une règle de validation, et la définition est le test : « Une règle de validation est un prédicat déterministe, synchrone et sans effet de bord sur un seul enregistrement — elle doit être décidable à partir de l’écriture entrante (et, en mise à jour, de l’enregistrement précédent) sans I/O. » Elles « s’exécutent automatiquement sur le chemin d’écriture (insert/update) ». Trois choses qui ressemblent à de la validation sont explicitement envoyées ailleurs : les vérifications asynchrones ou distantes vont dans la couche formulaire ; un handler personnalisé va dans un hook beforeInsert / beforeUpdate ; et les gardes à la suppression vont dans un hook beforeDelete, parce qu’« il n’existe pas d’événement de validation 'delete' ».
La documentation des hooks donne la règle de travail entre les deux autres maisons — « un flow est la surface par défaut pour la logique applicative ; un hook est le filet de sécurité pour ce qu’un graphe de nœuds ne peut pas exprimer » — et une table de six lignes. Fusionnée avec la définition de la validation, toute la décision tient en une :
| La règle… | Maison | Pourquoi |
|---|---|---|
| se décide à partir du seul enregistrement en cours d’écriture, sans I/O | Règle de validation sur l’objet | C’est un prédicat ; le moteur l’évalue à chaque insert et update, quel que soit l’auteur de l’écriture |
| doit modifier l’enregistrement en attente dans la même écriture, avant sauvegarde | Hook before | La ligne que la documentation réserve au hook : il réécrit la charge utile que le moteur s’apprête à sauvegarder ; un nœud de flow écrit comme un appelant ordinaire, dans sa propre écriture |
| est un invariant entre objets, sur chaque chemin d’écriture, quel que soit l’auteur | Hook | « Le devoir de filet de sécurité lui-même » |
intercepte des lectures (beforeFind / afterFind) | Hook | Les flows n’ont pas d’événements de lecture |
| a des effets de bord après sauvegarde — créer des enregistrements, notifier, appeler HTTP, demander une approbation | Flow (record_change) | La surface par défaut |
| se met en pause — approbations, écrans, minuteurs, signaux | Flow | « Un hook tourne en ligne et ne peut pas se mettre en pause » |
| tourne à l’horloge ou relativement à une date | Flow (schedule / timeRelative) | Déclare l’organisation au nom de laquelle il tourne |
| doit bloquer une suppression | Hook beforeDelete | Les règles de validation n’ont pas d’événement de suppression |
Deux choses dans cette table s’inversent facilement. Les règles de validation ne tournent jamais à la suppression — une suppression ne porte aucune charge utile d’enregistrement à valider —, donc « une opportunité avec des lignes ouvertes ne peut pas être supprimée » est un hook beforeDelete, pas une règle. Et « défaut » est le mot de la documentation, pas une préférence molle : quand un hook et un flow pourraient tous deux exprimer une règle, le flow l’emporte, sauf si la règle a besoin de quelque chose que seul un hook possède, c’est-à-dire l’enregistrement en attente ou un événement de lecture.
Sept règles, un CRM
L’exemple de CRM au tag 18.2.0 déclare sept règles métier sur ses six objets. Les voici, chacune avec la maison où elle vit et la ligne de la table qui l’y a placée. Le compte est le nombre du titre ; recomptez vous-même à partir des tableaux validations de src/objects/*.object.ts, plus src/hooks/ et src/flows/.
| # | La règle, en une phrase | Maison | Construction | Ligne de la table |
|---|---|---|---|---|
| 1 | Une remise supérieure à 40 % est refusée sans exception approuvée | Validation sur crm_opportunity — discount_cap | script, prédicat CEL | Décidable à partir du seul enregistrement |
| 2 | Une opportunité ouverte ne peut pas être créée avec une date de clôture passée | Validation sur crm_opportunity — opp_close_date_not_past | cross_field, events: ['insert'] | Même moteur de prédicat que script ; l’erreur s’attache à close_date |
| 3 | Les étapes avancent dans l’ordre ; closed_won est terminal | Validation sur crm_opportunity — opp_stage_transitions | state_machine | État précédent plus état suivant, sans I/O |
| 4 | Les statuts de lead avancent dans l’ordre ; converted et disqualified sont terminaux | Validation sur crm_lead — lead_status_transitions | state_machine | Idem |
| 5 | Un score de lead est compris entre 0 et 100 | Validation sur crm_lead — lead_score_range | script | Prédicat |
| 6 | La probabilité d’une affaire gagnée est 100, celle d’une affaire perdue est 0 | Hook — opportunity_stage_probability | beforeInsert, beforeUpdate | Modifie l’enregistrement en attente dans la même écriture |
| 7 | Convertir un lead crée un client et une opportunité et marque le lead comme converti — une seule fois | Flow — crm_convert_lead_wizard | Screen flow ; nœud update_record | Se met en pause pour la saisie, crée des enregistrements, écrit un autre objet |
La règle 6 est le seul hook, et elle vaut d’être lue en entier, parce qu’elle a exactement la forme que la table réserve aux hooks — une mutation de l’enregistrement à son entrée, et rien d’autre :
// examples/app-crm/src/hooks/opportunity.hook.ts (@objectstack/cli@18.2.0)
export const OpportunityStageHook = defineHook({
name: 'opportunity_stage_probability',
object: 'crm_opportunity',
events: ['beforeInsert', 'beforeUpdate'],
priority: 100,
handler: async (ctx: HookContext) => {
const input = ctx.input as { stage?: string; probability?: number };
if (input.stage === 'closed_won') input.probability = 100;
if (input.stage === 'closed_lost') input.probability = 0;
},
});
La règle 7 est le seul flow, et son dernier nœud est celui qui écrit. Son ensemble d’écriture n’est pas du code ; c’est une map littérale de noms de champs à côté d’un nom d’objet littéral, et les deux valeurs qu’il calcule sont des enveloppes de valeur CEL — toujours des données, pas un corps de fonction :
// examples/app-crm/src/flows/convert-lead.flow.ts (@objectstack/cli@18.2.0)
{
id: 'update_lead',
type: 'update_record',
label: 'Mark Lead Converted',
config: {
objectName: 'crm_lead',
filter: { id: { dialect: 'cel', source: 'recordId' } },
fields: {
status: 'converted',
account: { dialect: 'cel', source: 'account_id' },
converted_opportunity: { dialect: 'cel', source: 'opportunity_id' },
},
},
},
Mettez ces deux formes côte à côte. Elles sont tout l’argument.
Trois maisons à l’API
Démarrez l’exemple sur 18.2.0 (objectstack dev --compile), connectez-vous une fois comme l’admin de développement que le serveur crée sur une base vide — la bannière de démarrage imprime le compte — et pilotez l’API REST générée. Quatre appels, un par forme de règle.
Maison 1, une validation script. Créer une opportunité avec 45 % de remise :
POST /api/v1/data/crm_opportunity
{ "name": "Acme — Deep Discount Deal", "account": "<acme id>", "stage": "prospecting",
"amount": 50000, "discount_percent": 45 }
400 { "error": "Discount cannot exceed 40% without an approved exception.",
"code": "VALIDATION_FAILED",
"fields": [ { "field": "_record", "code": "rule_violation",
"message": "Discount cannot exceed 40% without an approved exception." } ],
"object": "crm_opportunity" }
Le même appel à discount_percent: 40 renvoie 201 et un enregistrement estampillé probability: 50, la valeur par défaut déclarée du champ. Le message est celui rédigé sur discount_cap ; l’ancre est _record, parce qu’une règle script simple s’attache à l’enregistrement entier.
Maison 1 encore, une validation cross_field. En créer une avec close_date dans le passé :
POST /api/v1/data/crm_opportunity
{ "name": "Acme — Back-dated Deal", "account": "<acme id>", "stage": "prospecting",
"amount": 10000, "close_date": "2026-01-15" }
400 { "error": "Close Date must be today or a future date.",
"code": "VALIDATION_FAILED",
"fields": [ { "field": "close_date", "code": "rule_violation",
"message": "Close Date must be today or a future date." } ],
"object": "crm_opportunity" }
Même moteur, même enveloppe, une différence : l’erreur est ancrée sur close_date, parce que cross_field « s’évalue à l’identique de script » et que fields[0] est ce qui « étiquette le champ auquel l’erreur s’attache ». C’est toute la distinction entre les deux types, et c’est un choix de présentation, pas d’application.
Maison 2, le hook before. Prenez « Acme — Q3 Platform Renewal », issue des données de départ — étape proposal, probabilité 70 — et clôturez-la :
PATCH /api/v1/data/crm_opportunity/WQwHS5B2yI9pOTqc
{ "stage": "closed_won" }
200 { "record": { "name": "Acme — Q3 Platform Renewal", "stage": "closed_won",
"probability": 100, "expected_revenue": 120000, … } }
GET /api/v1/data/crm_opportunity/WQwHS5B2yI9pOTqc
200 { "record": { "stage": "closed_won", "probability": 100, … } }
Personne n’a envoyé probability. Le hook a réécrit l’enregistrement en attente avant que le moteur ne le sauvegarde, et la relecture concorde. C’est la ligne de la table que la documentation réserve à un hook before — « modifier l’enregistrement en attente dans la même écriture, avant sauvegarde » —, parce que le nœud update_record d’un flow remet ses champs au moteur comme une charge utile d’appelant ordinaire, une écriture à part entière, plutôt que de modifier celle déjà en chemin.
Maison 1, une validation state_machine. Prenez « Initech — Expansion » — étape prospecting — et essayez de la clôturer d’un bond :
PATCH /api/v1/data/crm_opportunity/WsD7hH7IEgUvPhDK
{ "stage": "closed_won" }
400 { "error": "Invalid stage transition.",
"code": "VALIDATION_FAILED",
"fields": [ { "field": "stage", "code": "invalid_transition",
"message": "Invalid stage transition.", "label": "Stage",
"constraint": { "from": "prospecting", "to": "closed_won" } } ],
"object": "crm_opportunity" }
prospecting → qualification, une transition déclarée, renvoie 200. Que le hook ait réécrit probability avant que le prédicat ne refuse l’écriture est invisible de l’extérieur, et cela n’a pas d’importance : l’écriture n’a jamais atterri, et l’enregistrement reste ce qu’il était.
Trois maisons, trois enveloppes distinguables : un échec de règle porte VALIDATION_FAILED et le message rédigé ; un hook ne laisse aucune trace sinon la valeur qu’il a changée — et quand un hook refuse, dit la documentation, l’API répond 400 avec le code VALIDATION_ERROR, pas le VALIDATION_FAILED d’une règle — ; un flow — la règle 7 — est piloté depuis un écran, il n’a donc pas d’appel REST unique, ce qui est en soi un fait sur l’endroit où il vit.
La même coquille dans deux maisons
Voici la mesure sur laquelle repose la colonne « pourquoi » de la table. Prenez une erreur — un nom de champ mal orthographié dans une écriture — et plantez-la dans le flow et dans le hook. Puis lancez os validate sur 18.2.0.
Dans le nœud update_record du flow, écorchez converted_opportunity :
✗ Author-time rules failed (1 issue)
• flow "crm_convert_lead_wizard" › node "Mark Lead Converted": update_record
writes 'converted_oportunity', but object 'crm_lead' declares no such field,
so the write is refused (INVALID_FIELD / 400) and the step fails the run
fix: Did you mean 'converted_opportunity'? Fix the field name, or declare
'converted_oportunity' on the object
rule: flow-node-write-unknown-field at flows[0].nodes[6].config.fields.converted_oportunity
— `os explain flow-node-write-unknown-field` for why an undeclared field write is refused
exit=1
Dans le corps du hook, écorchez probability sous la forme littérale que le lint de l’ensemble d’écriture reconnaît — ctx.input.probabilty = 100 :
✓ Validation passed (542ms)
…
⚠ hook "opportunity_stage_probability" › body: body writes 'probabilty' to its
input, but object 'crm_opportunity' declares no such field, so the write is
refused (INVALID_FIELD / 400) (judged on the metadata body lowered from the
inline handler)
fix: Did you mean 'probability'? Fix the field name, or declare 'probabilty'
on the object
rule: hook-body-write-unknown-field at hooks[0].handler
— `os explain hook-body-write-unknown-field` for why an undeclared field write is refused
exit=0
Même erreur, même correction, deux forces. Les deux réponses écrivent la correction — Did you mean …? — et ce qui change, c’est la force et l’ancre : le nœud de flow est une erreur, ancrée sur la clé exacte de fields, avec une sortie non nulle ; le corps du hook est un avertissement, ancré sur le handler entier, et la construction est verte. La documentation dit pourquoi en une phrase : les fields du nœud « sont une map littérale à côté d’un objectName littéral, donc rien n’a pu les mal lire », tandis que l’ensemble d’écriture d’un corps de hook n’est vérifié « que pour les motifs littéraux qu’un parseur peut reconnaître ». L’existence d’un champ est une certitude quand l’écriture est des données et un meilleur effort quand l’écriture est du code. (Les lignes omises dans la seconde transcription sont le résumé du stack ; les neuf avis field-no-consumers que l’exécution propre imprime aussi suivent l’avertissement du hook.)
Et il y a une troisième réponse, qui est celle qui devrait changer votre façon de relire. Le hook du CRM, tel qu’il est livré, n’écrit pas ctx.input.probability directement. Il écrit à travers un alias local — const input = ctx.input; … input.probability = 100 —, et la documentation range l’entrée aliasée parmi les formes sur lesquelles la règle « renonce en silence », parce que « v1 ne fait aucune analyse de flux de données ». Écorchez le champ dans le hook exactement tel qu’il est écrit dans le dépôt et os validate ne dit rien : sortie 0, aucun avertissement, une sortie identique octet pour octet à l’exécution propre. La phrase de la documentation à ce sujet est celle à retenir : « Un avertissement absent n’est pas un certificat de bonne santé. »
Les trois atterrissent au même endroit à l’exécution. Le chemin d’écriture du CRM refuse un champ inconnu sur chaque driver, et il le fait aussi pour une charge utile tapée à la main :
PATCH /api/v1/data/crm_opportunity/emhPM9WKJqqGzaME
{ "probabilty": 100 }
400 { "error": "Unknown field 'probabilty' on object 'crm_opportunity'",
"code": "INVALID_FIELD", "field": "probabilty", "object": "crm_opportunity" }
L’erreur n’est donc jamais persistée. Ce qui change, c’est quand vous l’apprenez et qui vous le dit : le flow à validate, avec la correction écrite noir sur blanc ; le hook littéral à validate, à titre consultatif ; le hook aliasé à la première écriture réelle, par un refus qui nomme le champ et non le corps qui l’a écrit.
Relisible comme données, relisible comme code
Appelons cette propriété relisible comme données (reviewable as data). Une règle est relisible comme données quand son sens est porté par des métadonnées structurées que la porte peut résoudre contre le schéma déclaré — une map fields, une table transitions, une condition CEL —, de sorte que le relecteur lit un diff de valeurs et que le validateur lit les mêmes valeurs avec certitude. Une règle est relisible comme code (reviewable as code) quand son sens est porté par un corps de fonction : la porte peut parser les formes littérales et doit renoncer sur le reste, et le relecteur lit de la logique, pas des valeurs.
Chaque règle de validation du CRM est relisible comme données. La state_machine est le cas le plus net — un relecteur peut lire closed_won: [] et savoir, sans rien exécuter, qu’une affaire gagnée est terminale. Le nœud update_record du flow est relisible comme données ; la documentation le dit directement : « Le graphe de nœuds se compare champ par champ et s’affiche dans le designer de la Console avec l’historique d’exécution par nœud ; un corps de hook ne se relit que comme du code. » Le hook est relisible comme code, et le hook du CRM est un bon hook précisément parce que son code tient en trois lignes dont le travail n’est exprimable par aucune forme de données — réécrire l’enregistrement à son entrée.
Cela donne au relecteur d’un changement écrit par l’AI une question plus rapide que « est-ce correct ? » : cette règle est-elle dans la maison où elle est relisible comme données ? Un seuil dans un corps de hook, une transition de statut imposée par un if dans un handler, une approbation implémentée par un hook qui lève une erreur — chacun se parse, se construit et tourne, et chacun est une anomalie, parce que la même règle a une maison où la porte la vérifierait comme une valeur et où le relecteur la lirait comme une valeur. La correction n’est pas de relire le code plus durement. C’est de déménager la règle.
La documentation ferme la seule brèche de cet argument. La logique qui a vraiment besoin de code « n’a toujours pas besoin d’être un hook : un nœud script de flow appelant une fonction enregistrée via defineStack({ functions }) garde l’orchestration dans des métadonnées vérifiées et le code dans une unité nommée et testable ». Le code va dans la plus petite boîte ; la forme autour reste des données.
Ce que cela ne règle pas
- Le seuil. Rien ci-dessus ne dit si 40 % est le bon plafond, si la finance est le bon approbateur, ou si l’ordre des étapes correspond à la façon dont l’entreprise vend. Une règle dans la bonne maison reste une règle avec laquelle un humain doit être d’accord ; cet article rend seulement le désaccord bon marché à trouver.
cross_fieldn’est pas une vérification plus forte. Elle exécute le même prédicat quescript. Si vous la choisissez en attendant que le moteur fasse quelque chose de plus entre les champs, il ne le fera pas ; vous choisissez où atterrit le message d’erreur.- Le lint de l’ensemble d’écriture est borné par un parseur. Clés calculées, spreads, noms d’objet dynamiques, entrée aliasée et objets déclarés par un autre paquet lui sont tous invisibles, par conception — un faux positif tuerait un lint consultatif. Le filet de sécurité à l’exécution est le même sur chaque driver, mais c’est un filet de sécurité. La règle pratique de la documentation tient : traitez
hook-body-write-unknown-fieldcomme un échec de construction par convention, et vérifiez à la main ce que le parseur ne peut pas voir. - Le CRM n’a aucun flow déclenché par enregistrement. Son seul flow est un assistant d’écran lancé par un utilisateur. La moitié « quelqu’un approuve l’exception » de la phrase d’ouverture est un flow avec un nœud
approval, et le dépôt ObjectStack garde ses flows d’approbation travaillés dans l’exemple showcase, plus grand, pas ici. La table de décision le couvre ; ce CRM ne l’exerce pas. - Sept est le nombre de ce CRM, à
@objectstack/cli@18.2.0, et les trois passages de la documentation cités ci-dessus sont lus à ce même tag. Relancez la transcription sur la version que vous installez : entre deux versions récentes, le compte est resté à sept tandis que les valeurs calculées du nœud de flow ont changé de forme, de gabarits'{…}'à des enveloppes de valeur CEL — exactement le genre de dérive qu’une relance attrape.
Pointez votre agent vers la table
La façon la moins chère de mettre les règles dans la bonne maison est de donner la table à l’agent avant qu’il n’écrive la première. Trois lignes dans un AGENTS.md, ou dans le fichier de règles que lit votre agent, suffisent :
A business rule that can be decided from the one record being written, with no I/O,
is a validation rule on the object (script / cross_field / state_machine). A rule that
must change the record before it is saved is a before hook. Everything else — side
effects, approvals, anything that pauses or runs on a clock — is a flow. Flows are
the default; hooks are the backstop. Validation rules never run on delete.
Puis relisez le diff pour une seule chose : une règle qui vit là où elle est relisible comme code alors qu’il existe une maison où elle serait relisible comme données. La porte vous aura déjà dit laquelle — une fois comme erreur, une fois comme avertissement, et une fois, si le hook aliase son entrée, pas du tout.
C’est la boucle pour laquelle le format cible est construit. Une ontologie métier exécutable. L’AI l’écrit, le runtime l’exécute, les agents l’opèrent, vous la possédez. Les sept règles ci-dessus sont les verbes de l’ontologie, et chacune est dans un fichier que vous pouvez lire.
npm create objectstack@latest crm && cd crm
pnpm run validate # la porte, à chaque modification
pnpm run dev # l’API, avec l’admin de développement imprimé dans la bannière
À lire aussi : comment un CRM a été construit avec un agent, y compris ce qui a cassé (en anglais), où la state_machine de la règle 3 est apparue pour la première fois ; l’ordre de relecture en quatre passes (en anglais), auquel cet article ajoute une cinquième question ; le modèle de déclencheurs (en anglais), pour savoir où un flow commence ; et l’entrée du glossaire declared vs. enforced (en anglais), l’écart qu’une règle dans la mauvaise maison rouvre en silence.