ADR 0016 – Modèles de nœud en YAML (`config/templates/`)
Accepté (2026-10-15, mode autonome)
ADR 0016 – Modèles de nœud en YAML (config/templates/)
Statut : Accepté (2026-10-15, mode autonome)
Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles). Un modèle de nœud est un nouveau type de définition, config/templates/<name>.yaml, validé par un méta-schéma template.schema.json. Il décrit les valeurs initiales d’un nœud à créer : type, title, aspects (valeurs par aspect), tags et body (Markdown), avec un libellé et une icône. Il ne décrit pas de parent.
Seules deux variables sont reconnues, {{today}} et {{now}}, avec un décalage facultatif ± N d|w|m. C’est une grammaire fermée, sans SpEL. Elle est stricte dans title et dans les valeurs d’aspects (une variable inconnue rend le fichier invalide) et tolérante dans body (tout autre {{…}} y est laissé tel quel). Au chargement, le moteur vérifie :
- que le type et les aspects existent ;
- que chaque champ appartient au schéma de son aspect ;
- qu’aucune valeur ne vise un champ
node-ref; - que les valeurs, une fois les variables résolues, sont conformes au JSON Schema de l’aspect, champs requis exceptés.
Un modèle invalide est ignoré et signalé dans GET /api/definitions/status, comme les autres définitions.
Le modèle préremplit le formulaire et n’écrit rien. GET /api/definitions/templates liste les modèles bruts. GET /api/definitions/templates/{name}/draft?zone= renvoie un brouillon dont les variables sont résolues dans le fuseau de l’utilisateur. Le front préremplit /nodes/new?template=<name> avec ce brouillon. L’utilisateur le complète, puis l’enregistre par le POST /api/nodes habituel. Le nom du modèle accompagne la requête (CreateNodeRequest.template) et sert uniquement de provenance : il est inscrit dans le payload de node.created. La palette gagne un groupe « Modèles » (« Modèle : <libellé> »), et la page des définitions une section « Modèles » avec un bouton « Créer ».
Il n’y a pas de migration. Les méta-schémas des aspects, types et liens ne changent pas.
Contexte
Le cahier (« Saisie et capture », MVP) prévoit des templates de nœud : « Fichier YAML : type, aspects, valeurs et contenu initiaux ». Il prévoit aussi des valeurs par défaut (« Modèle de contenu », MVP) : « Défauts et templates de valeurs par aspect ». La ligne de backlog est « Templates de nœud en YAML ».
État du code :
-
Moteur de définitions (
DefinitionLoader, sans Spring). Il litconfig/aspects,config/typesetconfig/links, et valide chaque fichier contredefinitions/meta/<kind>.schema.json. Il imposenameégal au nom du fichier. Il vérifie ensuite les références croisées : aspects d’un type, contraintessource/targetd’un lien,x-targetd’un champ référence. Un fichier invalide produit unLoadIssueet ne fait pas tomber l’application.DefinitionRegistry.keepPreviousOnErrorgarde au rechargement la version valide précédente d’une définition devenue invalide.DefinitionsControllerexpose/aspects,/types,/linkset/status. -
Rien n’est prévu pour les modèles : il n’existe ni dossier
config/templates, ni clé de type, ni ligne dans le tableau d’état du skilldefinitions-format. Les dossiersconfig/viewsetconfig/dashboardsexistent mais ne sont pas chargés (jalon 2). -
Expressions : le skill décrit SpEL (
when,where, champs calculés) et les gabarits{{title}},{{facture.echeance}}des règles. Aucune n’est évaluée aujourd’hui :actionsetrulessont chargées « en structure libre » pour le jalon 3, et aucune classe SpEL n’existe. Les variablestoday,nowet les durées7d,2w,1my sont prévues. -
Création (
NodeService.create) :- le type est résolu (400
types’il est inconnu) ; - un aspect vide est ajouté par aspect obligatoire, puis fusionné avec les données fournies ;
- chaque aspect passe par
prepareAspect:applyDefaults(valeursdefaultde premier niveau),canonicalizeRefs, puis validation JSON Schema ; les références et le corps (liens wiki,#tags) sont ensuite contrôlés ; - les tags sont normalisés par
node.Tags, classe package-private du paquetnode; - le tout est écrit, avec l’événement
node.created.
CreateNodeRequestportetitle,type,body,parentId,aspects,tagsetinbox. - le type est résolu (400
-
Front :
/nodes/newaccepte?type=(selectTypeajoute les sections obligatoires),?parent=et?title=;- en création, les valeurs
defaultsont prérenseignées par FormlyJsonschema ; - la palette (ADR 0014) génère « Créer :
» depuis config/types/, ouvre le formulaire et ne crée jamais directement ; DefinitionsStoremet en cache aspects, types et liens ;- la page des définitions liste aspects, types et liens, ainsi que les problèmes de chargement.
-
Changement de type (ADR 0015) : sans rapport direct, mais il montre la règle suivie. Un aspect obligatoire n’est jamais écrit sans ses champs requis, et un formulaire les fait saisir.
-
Aucun fuseau horaire n’est configuré côté serveur (aucun
ClockniZoneIddans le code). L’image Docker tourne par défaut en UTC.
Trois choix sont difficiles à défaire :
- Le format du fichier : les utilisateurs l’écrivent à la main et l’API des définitions (V2) l’écrira.
- La syntaxe des variables : les modèles écrits aujourd’hui doivent rester valides quand SpEL arrivera au jalon 3.
- L’endroit où s’applique le modèle : le contrat de
POST /api/nodesen dépend, ainsi que la façon dont les scripts et le futur moteur d’actions créeront des nœuds.
Décision
Format : config/templates/<name>.yaml
# config/templates/facture-papier.yaml
name: facture-papier # kebab-case, égal au nom du fichier
label: Facture reçue par la poste # affiché dans la palette et la page des définitions
icon: local_post_office # Material Symbols ; à défaut, icône du type, puis note_add
type: facture-fournisseur # facultatif : type du nœud créé (config/types/)
title: "Facture du {{today}}" # facultatif : titre initial
tags: [administratif] # facultatif : tags saisis
aspects: # facultatif : valeurs initiales par aspect
courrier:
canal: poste
dateReception: "{{today}}"
facture:
echeance: "{{today + 30d}}" # montant (requis) reste à saisir dans le formulaire
body: | # facultatif : corps Markdown initial
## Notes
Sémantique :
- Clés plates, sans bloc
node:: c’est le format le plus court à écrire. Les clés propres au modèle (name,label,icon) ne se confondent pas avec celles du nœud (type,title,aspects,tags,body), qui reprennent les noms deCreateNodeRequest. typeest facultatif. Un modèle sans type (journal, note libre) est permis. Les aspects obligatoires du type n’ont pas à figurer dansaspects: le formulaire puis le serveur les ajoutent, comme pour?type=.aspectsassocie un nom d’aspect à des valeurs de premier niveau. Un aspect sans valeur (document:oudocument: {}) est simplement ajouté au nœud. Les aspects qui ne sont pas imposés par le type sont ajoutés comme aspects facultatifs. Les champs requis peuvent manquer : ils seront saisis dans le formulaire. Les valeursdefaultdu schéma s’appliquent aux champs absents, côté front (Formly) comme côté serveur (applyDefaults), sans que le modèle ait à les répéter.- Aucune référence à un nœud : ni
parent, ni valeur pour un champformat: node-ref. Un UUID écrit dansconfig/n’est pas portable d’une instance à l’autre, et une restauration ou une purge le rend pendant. Il n’existe pas encore d’identifiant de nœud lisible (voir Reporté). La restriction se lève sans rien casser. tags: liste de tags saisis, de 50 au plus et de 100 caractères chacun (limites de l’ADR 0006). Ils sont normalisés à la création, pas au chargement (voir Validation).body: Markdown quelconque (liens wiki,#tags, callouts), de 200 000 caractères au plus. Il est indexé à la création comme tout corps.- Pas de clé
inbox: la création par formulaire n’entre pas dans l’inbox (ADR 0014).
Variables : une grammaire fermée, sans SpEL
variable := "{{" espaces base [ espaces signe espaces nombre unité ] espaces "}}"
base := "today" | "now"
signe := "+" | "-"
nombre := 1 à 4 chiffres
unité := "d" (jours) | "w" (semaines) | "m" (mois)
| Variable | Valeur, pour le 15 octobre 2026 à 9 h 30 à Bruxelles |
|---|---|
{{today}} |
2026-10-15 (ISO 8601, format: date) |
{{today + 30d}}, {{today-1w}} |
2026-11-14, 2026-10-08 |
{{today + 1m}} |
2026-11-15 (plusMonths : le 31 janvier + 1m donne le 28 ou 29 février) |
{{now}} |
2026-10-15T09:30:00+02:00 (ISO 8601 avec décalage, à la seconde, format: date-time) |
{{now + 1d}} |
2026-10-16T09:30:00+02:00 |
- Les variables s’écrivent dans
title,bodyet toute chaîne des valeurs d’aspects, à toute profondeur, y compris dans un tableau. Elles peuvent occuper toute la chaîne ("{{today}}") ou une partie ("Réunion du {{today}}"). Le résultat est toujours une chaîne. Les nombres, booléens,tags,type,name,labeleticonne sont pas interprétés. - Contrôle strict dans
titleet dans les valeurs d’aspects : tout{{…}}qui n’est pas une variable reconnue ({{titre}},{{today + 3j}},{{ }}) rend le fichier invalide. Ces chaînes sont courtes et typées : une accolade double y est une faute de frappe. - Contrôle tolérant dans
body: seules les variables reconnues sont remplacées, et tout autre{{…}}reste tel quel. Un corps Markdown peut légitimement contenir{{ valeur }}dans un bloc de code (Angular, Jinja, Mustache). En contrepartie, une faute de frappe dans le corps reste visible dans le formulaire. - Pas de SpEL : aucune évaluation d’expression, aucun accès aux propriétés, aucune variable de nœud (
{{title}}). La grammaire est un sous-ensemble strict du langage prévu au jalon 3 (today,now, durées7d,2w,1m). Quand SpEL arrivera, tout modèle valide aujourd’hui gardera son sens ; d’autres variables pourront s’ajouter sans invalider l’existant. - Fuseau : les variables sont résolues dans le fuseau IANA demandé par le client (
zone). Le front envoie celui du navigateur. Sanszone, c’est le fuseau de la JVM (ZoneId.systemDefault(), donc la variableTZdu conteneur). - YAML : une valeur qui commence par
{doit être entre guillemets (title: "{{today}}"). Sans guillemets, YAML la lit comme un objet et le méta-schéma refuse le fichier (« must be string »). Le skilldefinitions-formatle rappelle.
Classe pure, dans le paquet definitions :
/**
* Variables des modèles de nœud (ADR 0016) : {{today}} et {{now}}, avec un décalage facultatif ± N d|w|m.
* Grammaire fermée : aucune expression n'est évaluée.
*/
public final class TemplateVariables {
/** Tout texte entre doubles accolades, sans accolade intérieure. */
static final Pattern CANDIDATE = Pattern.compile("\\{\\{([^{}]*)}}");
static final Pattern VARIABLE =
Pattern.compile("\\s*(today|now)\\s*(?:([+-])\\s*(\\d{1,4})\\s*([dwm]))?\\s*");
/** Remplace les variables reconnues ; tout autre {{…}} est laissé tel quel. */
public static String resolve(String text, ZonedDateTime now);
/** Applique {@link #resolve} à toutes les chaînes d'une valeur (Map, List), sans modifier l'original. */
public static Object resolveDeep(Object value, ZonedDateTime now);
/** Les {{…}} de {@code text} qui ne sont pas des variables reconnues, dans l'ordre (contrôle strict). */
public static List<String> unknown(String text);
}
today vaut now.toLocalDate() décalé (plusDays, plusWeeks, plusMonths). now vaut now.truncatedTo(SECONDS) décalé dans le fuseau, puis converti par toOffsetDateTime() : l’heure locale est conservée quand le décalage franchit un changement d’heure (amendé après revue). Le formatage utilise ISO_LOCAL_DATE et ISO_OFFSET_DATE_TIME.
Méta-schéma backend/src/main/resources/definitions/meta/template.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Modèle de nœud",
"type": "object",
"required": ["name", "label"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"label": { "type": "string", "minLength": 1 },
"icon": { "type": "string" },
"type": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Type du nœud créé (nom d'un fichier de config/types/)"
},
"title": {
"type": "string",
"maxLength": 500,
"description": "Titre initial ; variables {{today}} et {{now}}"
},
"aspects": {
"type": "object",
"description": "Valeurs initiales par aspect ; variables {{today}} et {{now}} dans les chaînes",
"propertyNames": { "pattern": "^[a-z][a-zA-Z0-9]*$" },
"additionalProperties": {
"type": ["object", "null"],
"propertyNames": { "pattern": "^[a-z][a-zA-Z0-9]*$" }
}
},
"tags": {
"type": "array",
"maxItems": 50,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "maxLength": 100 }
},
"body": {
"type": "string",
"maxLength": 200000,
"description": "Corps Markdown initial ; variables {{today}} et {{now}}, tout autre {{…}} laissé tel quel"
}
}
}
Validation au chargement
DefinitionLoader gagne TEMPLATES = "templates" et le méta-schéma correspondant. Les modèles sont lus après les liens, puisqu’ils dépendent des aspects et des types chargés. Comme pour les types, les références croisées portent sur les définitions chargées : un modèle qui vise un type dont le fichier est invalide est lui-même ignoré.
// DefinitionLoader
public static final String TEMPLATES = "templates";
/**
* Contrôles d'un modèle (ADR 0016) au-delà du méta-schéma, dans cet ordre. Les messages sont préfixés par le
* chemin (title, aspects.<a>, aspects.<a>.<champ>).
*/
private List<String> checkTemplate(TemplateDefinition template, Map<String, AspectDefinition> aspects,
Map<String, List<NodeRefField>> refFields, Map<String, TypeDefinition> types);
type, s’il est présent, est un type chargé. Sinon : « Type inconnu :». - Chaque clé de
aspectsest un aspect chargé. Sinon : « Aspects inconnus : a, b ». Les contrôles suivants sont faits aspect par aspect et ne concernent que les aspects connus. - Chaque champ est une propriété de premier niveau du schéma de l’aspect, même si le schéma admet des propriétés supplémentaires. Sinon : « aspects.facture.echance : champ inconnu de l’aspect facture ». Une faute de frappe serait sinon stockée en silence.
- Aucun champ
node-ref: « aspects.tache.projet : une référence à un nœud n’est pas admise dans un modèle ». - Variables strictes :
TemplateVariables.unknownsurtitleet sur chaque chaîne des valeurs d’aspects. Sinon : « title : variable inconnue {{titre}} (admises : today, now, avec un décalage ± N d, w ou m) ». - Conformité, champs requis exceptés : les valeurs, une fois les variables résolues à une date de référence fixe, sont validées contre une copie du schéma de l’aspect privée de son
requiredracine. La copie est compilée parJsonSchemas.compileà chaque chargement. Lesrequiredimbriqués restent en vigueur. Le contrôle repèrecanal: fax(horsenum),echeance: "demain"(format: date),montant: -1(minimum), et{{now}}placé dans un champformat: date. Les valeurs résolues ont toujours la même forme, quelle que soit la date : une date fixe suffit. Le message est « aspects.facture :».
Les tags ne sont contrôlés que par le méta-schéma (chaînes de 1 à 100 caractères, 50 au plus). Leur forme canonique (node.Tags, package-private) n’est appliquée qu’à la création. Faire dépendre definitions de node créerait un cycle entre paquets. Un tag refusé produit donc un 400 tags visible dans le formulaire, et le test d’intégration des exemples (voir Tests) garantit que ceux de config/ passent.
Le corps n’est contrôlé que par le méta-schéma (longueur). Ses liens wiki et ses #tags sont traités à la création.
Moteur (paquet definitions)
/**
* Modèle de nœud (ADR 0016) : valeurs initiales du formulaire de création, non résolues. Les chaînes de title, body
* et aspects peuvent contenir les variables de {@link TemplateVariables}.
*
* @param type type du nœud créé, ou null
* @param aspects valeurs par aspect, dans l'ordre du fichier ; un aspect sans valeur vaut {}
*/
public record TemplateDefinition(
String name,
String label,
String icon,
String type,
String title,
Map<String, Map<String, Object>> aspects,
List<String> tags,
String body) implements NamedDefinition {
public TemplateDefinition {
// aspects : LinkedHashMap non modifiable (l'ordre du fichier donne celui des sections du formulaire),
// valeur null -> Map.of() ; tags null -> List.of()
}
}
/**
* Brouillon d'un nœud tiré d'un modèle (ADR 0016) : valeurs initiales, variables résolues. Rien n'est écrit.
*
* @param template nom du modèle, à renvoyer dans CreateNodeRequest.template
*/
public record NodeDraft(
String template,
String label,
@JsonInclude(NON_NULL) @Schema(requiredMode = NOT_REQUIRED) String type,
@JsonInclude(NON_NULL) @Schema(requiredMode = NOT_REQUIRED) String title,
@JsonInclude(NON_NULL) @Schema(requiredMode = NOT_REQUIRED) String body,
Map<String, Map<String, Object>> aspects,
List<String> tags) {
}
/** Construction du brouillon, sans accès BD ni horloge : testable unitairement. */
final class TemplateDrafts {
/** Copie profonde de la définition, puis TemplateVariables.resolve sur title et body, et resolveDeep sur aspects. */
static NodeDraft draft(TemplateDefinition template, ZonedDateTime now);
}
DefinitionsgagneMap<String, TemplateDefinition> templates(copie immuable, comme les autres). DansDefinitionRegistry:- un accesseur
Optional<TemplateDefinition> template(String name); keepPreviousOnErrorgagne la brancheTEMPLATES;- le journal de chargement compte les modèles.
- un accesseur
LoadIssue.kindadmettemplates.- Horloge : un bean
Clock(Clock.systemUTC()) est déclaré dans une nouvelle classecom.anorys.secondbrain.TimeConfig. Le fuseau ne vient pas de l’horloge mais de la requête. Les tests d’intégration le remplacent par une horloge fixe (@TestBean). Le jalon 3 (règles,todaydans les vues) réutilisera ce bean.
API
| Méthode | Chemin | Réponse |
|---|---|---|
GET |
/api/definitions/templates |
200 TemplateDefinition[], triés par label, variables non résolues |
GET |
/api/definitions/templates/{name}/draft?zone=<IANA> |
200 NodeDraft ; 404 si le modèle est inconnu ou ignoré ; 400 zone si le fuseau est inconnu |
GET |
/api/definitions/status |
DefinitionsStatus gagne templates (nombre de modèles chargés) |
POST |
/api/nodes |
CreateNodeRequest gagne template (provenance, facultatif) ; 400 template si le nom est mal formé |
// DefinitionsController (injecte Clock)
@GetMapping("/templates")
@Operation(summary = "Liste les modèles de nœud (config/templates/), variables {{today}} et {{now}} non résolues")
public List<TemplateDefinition> templates();
@GetMapping("/templates/{name}/draft")
@Operation(summary = "Brouillon d'un nœud tiré d'un modèle : variables résolues dans le fuseau demandé ; rien n'est "
+ "écrit. L'enregistrer par POST /api/nodes, avec template = nom du modèle")
public NodeDraft templateDraft(
@PathVariable String name,
@Parameter(description = "Fuseau IANA, par exemple Europe/Brussels ; absent = fuseau du serveur")
@RequestParam(required = false) String zone);
// ZoneId.of(zone) : une DateTimeException donne ValidationFailedException.of("zone", "Fuseau horaire inconnu : " + zone)
// modèle absent : NotFoundException("Modèle introuvable : " + name)
// TemplateDrafts.draft(template, ZonedDateTime.now(clock.withZone(zoneId)))
public record DefinitionsStatus(Instant loadedAt, int aspects, int types, int links, int templates,
List<LoadIssue> issues) { }
// NodeDtos.CreateNodeRequest : nouveau dernier composant
@Schema(description = "Modèle qui a prérempli la requête (ADR 0016) : enregistré dans node.created, n'applique rien")
@Pattern(regexp = "^[a-z][a-z0-9-]*$") @Size(max = 100) String template
// Les constructeurs de commodité (5 et 6 arguments) passent template = null. Un constructeur de 7 arguments
// (… tags, inbox) est ajouté pour les appels qui passent inbox (NodeFileService, tests).
- Le brouillon est une lecture (
GET) : il ne crée rien et sa réponse ne dépend que du modèle et de l’instant. Le préremplissage, la saisie des champs requis et l’enregistrement restent ceux d’une création ordinaire. Toutes les règles dePOST /api/nodess’appliquent sans exception : aspects obligatoires, valeurs par défaut, validation, références, corps, tags etnode_event. templatedansPOST /api/nodesn’applique rien. Il est recopié dans le payload denode.created("template": "facture-papier", ounull). Son existence n’est pas vérifiée : un modèle retiré ou devenu invalide entre l’ouverture du formulaire et l’enregistrement ne doit pas bloquer une saisie. Seul le format du nom est contrôlé.- Les scripts suivent le même chemin que le front : ils lisent le brouillon, le complètent, puis font le
POST. Une création en un seul appel est reportée (voir Reporté).
Événements et migration
- Il n’y a pas de nouvel événement. Le payload de
node.createdgagne la clétemplate(nom ounull). Une création depuis un modèle produit donc les mêmes événements qu’une création manuelle, et les règlesnode-createdetaspect-addeddu jalon 3 s’y appliquent de la même façon. - Il n’y a aucune migration.
Front
Client régénéré (skill api-contract) : DefinitionsService.templates(), DefinitionsService.templateDraft(name, zone), les modèles TemplateDefinition et NodeDraft, et CreateNodeRequest.template.
-
DefinitionsStoregagnetemplates$(shareReplay(1)). -
core/time-zone.ts:browserTimeZone(): string | undefined, qui renvoieIntl.DateTimeFormat().resolvedOptions().timeZonedans untry/catch. -
core/node-template.ts, fonction pure :export interface CreationDefaults { title: string; type: string | null; body: string; tags: string[]; aspects: Record<string, Record<string, unknown>>; } /** Valeurs initiales du formulaire de création : les paramètres explicites (?title=, ?type=) l'emportent sur le brouillon. */ export function creationDefaults(draft: NodeDraft | null, params: { title?: string; type?: string }): CreationDefaults; -
Éditeur (
node-editor-page.ts), en création seulement :-
nouvelle entrée
templateParam = input<string | undefined>(undefined, { alias: 'template' }), ajoutée à la clé de rechargement (id,parent,title,template) ; -
le
forkJoinde chargement gagnedraft: !id && template ? this.loadDraft(template) : of(null).loadDraftappelletemplateDraft(name, browserTimeZone()). Sur le modèle deloadParent, un 404 affiche « Modèle introuvable :. Formulaire vide. » et continue sans brouillon ; toute autre erreur interrompt le chargement ; -
application, avant de fixer
baseline:creationDefaults;title,bodyettags;- une section par aspect du brouillon (
section(name, structuredClone(data), false)), dans l’ordre du fichier. Un aspect inconnu du front rejointunknownAspects; - enfin
selectType(type), qui marque les sections obligatoires et ajoute celles qui manquent.
Formly complète les champs absents avec les valeurs
default; -
le formulaire prérempli n’est pas « modifié » :
baseline = stateKey()après l’application. Quitter sans rien toucher ne demande pas de confirmation, et rien n’est perdu ; -
sous le titre « Nouveau nœud », une ligne discrète « Modèle :
-
create()envoietemplate: this.templateName() ?? undefined.templateNamen’est fixé que si le brouillon a été chargé.
-
-
Palette (
core/palette/palette-items.ts) :/** « Modèle : <label> » par modèle (tri fr) ; icône du modèle, sinon celle de son type, sinon note_add ; route /nodes/new?template=<name>. */ export function templateCommands(templates: readonly TemplateDefinition[], types: readonly TypeDefinition[]): PaletteCommand[];PaletteCommand.groupgagne'template', avec l’intitulé de groupe « Modèles ». Les mots-clés sontcréer,nouveau,nouvelle,modèleet le nom technique.- Ordre des groupes : saisie vide, « Créer », « Modèles », « Aller à », « Modifiés récemment » ; saisie non vide, « Créer », « Modèles », « Aller à », « Capturer », « Nœuds ». Les éléments locaux précèdent toujours les résultats réseau, et l’option active par défaut reste déterministe (ADR 0014).
command-palette.tscombinetypes$ettemplates$.
-
Page des définitions :
- une section « Modèles », avec une carte par modèle : icône, libellé,
name, type et aspects ; - sur chaque carte, un bouton « Créer » (
routerLink/nodes/new,queryParams{ template }), qui offre un accès sur mobile sans passer par la palette ; - la ligne d’état compte les modèles.
- une section « Modèles », avec une carte par modèle : icône, libellé,
Documentation
- Skill
definitions-format:- le tableau d’état gagne la ligne «
templates| Oui | Jalon 1 (ADR 0016) : préremplissage de/nodes/new?template=, brouillonGET …/templates/{name}/draft» ; - nouvelle section « Modèle —
config/templates/<name>.yaml» : exemple, sémantique, variables, guillemets YAML, interdiction desnode-refet du parent ; - la section « Expressions » précise que les modèles n’acceptent que
todayetnow, sans SpEL.
- le tableau d’état gagne la ligne «
CLAUDE.md: la ligneconfig/de l’architecture mentionne les modèles.- Backlog : la case est cochée, avec un résumé et les éléments reportés.
Exemples dans config/templates/
facture-papier.yaml(voir plus haut) : un type dont les aspects obligatoires ont des champs requis sans défaut (facture.montant). Il couvre l’interpolation dans le titre,{{today}},{{today + 30d}}, uneenumet un tag.compte-rendu-reunion.yaml: typenote, titre"Réunion du {{today}}", aspect facultatifdocument(dateDocument: "{{today}}"), tagreunion, corps en sections (## Participants,## Points abordés,## Décisions,## Actions).tache-semaine.yaml: typetache,tache: { echeance: "{{today + 1w}}", priorite: haute }, sans titre.
Tests
Backend, unitaires :
TemplateVariablesTest:todayetnow(troncature à la seconde, décalage du fuseau) ;- décalages
d,wetm, positifs et négatifs, dont le 31 janvier + 1m ; - espaces tolérés (
{{ today + 7d }}) ; - plusieurs variables dans une chaîne, et texte sans variable inchangé ;
unknown:{{title}},{{today + 3j}},{{ }},{{today+12345d}};{{ valeur }}laissé tel quel parresolve;resolveDeep: chaînes dans des Map et des List, valeurs non textuelles intactes, original non modifié.
TemplateDraftsTest:- résolution dans
title,bodyetaspects; tags,typeetlabelrecopiés ;- champs absents à
null; - la définition n’est pas modifiée.
- résolution dans
DefinitionLoaderTest:- un modèle valide se charge, aspects dans l’ordre du fichier,
document:vide donnant{}; - les fichiers suivants sont refusés : type inconnu, aspect inconnu, champ inconnu, valeur hors
enum,{{now}}dans un champformat: date, valeur pour unnode-ref, variable inconnue danstitleou dans une valeur, clé inconnue (parent),namedifférent du nom du fichier, titre non cité (title: {{today}}) ; - sont acceptés : un champ requis absent,
{{ x }}dansbody; - un modèle qui vise un type au fichier invalide est ignoré ;
- un modèle invalide n’empêche pas les autres de se charger.
- un modèle valide se charge, aspects dans l’ordre du fichier,
DefinitionRegistryTest: un modèle devenu invalide au rechargement garde sa version précédente.ExampleDefinitionsTest: aucun problème de chargement, ettemplatescontientcompte-rendu-reunion,facture-papierettache-semaine.
Backend, intégration (NodeTemplateIT, Testcontainers, Clock fixe par @TestBean au 2026-10-14T23:30:00Z) :
GET /api/definitions/templates: trois modèles triés par libellé,{{today}}non résolu.- Brouillon de
facture-papier:- avec
zone=Europe/Brussels:dateReceptionvaut2026-10-15etecheance2026-11-14; - avec
zone=UTC:2026-10-14; zone=Mars/Olympusdonne 400zone; un modèle inconnu donne 404.
- avec
- Chaque exemple est enregistrable : brouillon, puis
POST /api/nodesavectemplate(en ajoutantfacture.montantpourfacture-papier), puis 201.- le nœud porte les aspects du type, les valeurs du modèle et les défauts (
facture.payee = false) ; - les tags sont canoniques ;
node.createdcontient"template": "facture-papier".
- le nœud porte les aspects du type, les valeurs du modèle et les défauts (
POST /api/nodesavectemplate: "Pas un nom"donne 400template. Avec un nom bien formé mais inconnu, la réponse est 201 et le nom est enregistré tel quel.GET /api/definitions/status:templatesvaut 3.OpenApiContractIT: présence de/api/definitions/templateset de/api/definitions/templates/{name}/draft.
Front (Vitest) :
node-template.spec.ts:creationDefaults.?title=et?type=l’emportent sur le brouillon ; sans brouillon, on obtient les valeurs vides.palette-items.spec.ts:templateCommands: libellé, replis d’icône, route, trifr, mots-clés ;- ordre des groupes avec « Modèles » ;
- « réunion » puis Entrée ouvre le modèle ;
- une saisie sans correspondance est toujours capturée.
node-editor-page-template.spec.ts:?template=appelletemplateDraftavec le fuseau ;- titre, corps, tags et sections sont préremplis, les sections obligatoires du type sont ajoutées et marquées non retirables ;
- la page n’est pas marquée modifiée ;
- la création envoie
template; - un 404 affiche le message, laisse le formulaire vide et n’envoie pas
template.
definitions-page.spec.ts(nouveau) : section « Modèles », lien « Créer » vers/nodes/new?template=…, compte dans la ligne d’état.
Découpage
-
Chargement et liste (
feat(definitions): modèles de nœud chargés depuis config/templates) :template.schema.json,TemplateDefinition,TemplateVariables(nécessaire au contrôle strict),checkTemplate,Definitions.templates, registre etkeepPreviousOnError;GET /api/definitions/templatesetDefinitionsStatus.templates;- les trois exemples de
config/templates/; - les tests unitaires,
ExampleDefinitionsTest, et la liste et le statut en intégration ; - skill
definitions-formatetCLAUDE.md.
Testable seule :
GET /api/definitions/statuset la liste dans Swagger UI, et un modèle cassé exprès apparaît dansissues. -
Brouillon et provenance (
feat(definitions): brouillon d'un modèle et provenance à la création) :TimeConfig(Clock),NodeDraft,TemplateDraftsetGET …/templates/{name}/draft;CreateNodeRequest.templateet le payload denode.created;NodeTemplateITetOpenApiContractIT.
Testable seule par Swagger UI : on lit le brouillon, puis on fait le
POST. -
Front (
feat(nodes): créer un nœud depuis un modèle) :- client régénéré,
templates$,browserTimeZone,creationDefaults; ?template=dans l’éditeur ;- groupe « Modèles » de la palette ;
- section « Modèles » de la page des définitions ;
- tests ;
- backlog.
- client régénéré,
Alternatives écartées
- Appliquer le modèle côté serveur à la création (
POST /api/nodesavectemplate, valeurs fusionnées sous la requête). L’utilisateur ne verrait pas les valeurs avant d’enregistrer, et les champs requis sans défaut (facture.montant) feraient échouer la création. Surtout, la fusion rajouterait ce que l’utilisateur a retiré du formulaire (un tag supprimé, un champ vidé), sauf à inventer une sémantique de suppression. Le brouillon garde le serveur seul maître des variables, et lePOSTreste inchangé. - Résoudre les variables dans le front, à partir de la liste brute. Cela économise un endpoint mais duplique la grammaire en TypeScript : il faudrait une table de cas partagée, comme pour les
#tags(ADR 0011), et les scripts ne pourraient pas obtenir un brouillon. Le principe API-first veut que tout ce que fait l’interface passe par l’API. POST /api/nodes/from-template/{name}(création en un appel, avec des surcharges) : un second chemin de création à maintenir, avec ses propres règles de fusion, avant d’en avoir l’usage. Le moteur d’actions du jalon 3 (createNode) en aura besoin. Il pourra s’appuyer surTemplateDrafts(voir Reporté).- SpEL dès maintenant (
SimpleEvaluationContext) : cela anticipe le jalon 3. Il faudrait fixer dès aujourd’hui les variables exposées (title, aspects), le contexte d’un nœud qui n’existe pas encore et le formatage des dates, sans cas d’usage. La grammaire fermée est un sous-ensemble du futur langage. - Gabarits complets (Mustache, Handlebars, Pebble) : une dépendance de plus, avec des boucles et des conditions sans usage, et une syntaxe qui divergerait des gabarits SpEL du jalon 3.
- Variables tolérantes partout (un
{{…}}inconnu laissé tel quel, y compris dans le titre et les valeurs) : une faute de frappe ({{todya}}) passerait le chargement, puis échouerait à la création surformat: date, loin de sa cause. Variables strictes partout : un modèle de note technique ne pourrait pas contenir un bloc de code Angular ou Jinja. - Variables dans
defaultdes schémas d’aspect (default: "{{today}}") :defaultdoit être une valeur valide de l’instance (JSON Schema). FormlyJsonschema l’afficherait brute, etapplyDefaultsl’écrirait telle quelle. Les défauts dynamiques par aspect sont reportés et passeront par une annotation dédiée (voir Reporté). - Format imbriqué (
node: { type, title, aspects }, ouvalues:) : un niveau d’indentation de plus à écrire à la main, sans conflit de noms à éviter. - Modèle déclaré dans le fichier de type (
templates:sousconfig/types/<t>.yaml) : on ne pourrait pas avoir de modèle sans type, ni plusieurs modèles d’un même type sans gonfler le fichier. Une définition par fichier, avec unname, suit la règle de l’ADR 0002. - Accepter
parentet lesnode-refpar UUID : des identifiants non portables dans des fichiers versionnés, rendus pendants par une purge, et un 400 « parent introuvable » à chaque création. On attend un identifiant de nœud lisible. - Normaliser les tags au chargement en rendant
node.Tagspublic : cela crée une dépendancedefinitions → node, alors quenodedépend déjà dedefinitions. La normalisation à la création et le test d’intégration des exemples suffisent. - Vérifier l’existence du modèle dans
POST /api/nodes: la provenance n’est qu’informative. Refuser une création parce qu’un fichier deconfig/a changé pendant la saisie ferait perdre du travail. - Pas de provenance du tout : cela économise un champ, mais l’information est perdue définitivement pour les nœuds créés d’ici là. Le jalon 3 (règles « créé depuis le modèle X ») et l’historique pourront s’en servir. Le coût est d’une clé dans un payload.
- Fuseau fixé par configuration serveur seulement (
app.zone) : une seule valeur pour tous les clients. Le paramètrezonedonne la date de l’utilisateur sans configuration. La propriété serveur pourra s’ajouter au jalon 3, pour les règles, et servir de défaut. - Commandes de modèle dans le groupe « Créer » : un modèle et un type de même libellé seraient indiscernables. Le groupe « Modèles » et le préfixe « Modèle : » les distinguent, y compris pour un lecteur d’écran.
- Sélecteur « Partir d’un modèle » dans le formulaire de création : il faudrait fusionner un brouillon avec une saisie déjà commencée. On choisit le modèle avant d’ouvrir le formulaire, par la palette ou par la page des définitions.
Conséquences
- Créer un nœud récurrent tient en quelques gestes : Ctrl+K, « réun », Entrée ; le formulaire arrive prérempli (titre daté, sections, tags) ; Entrée dans le titre enregistre. Sur mobile, on passe par le bouton de la palette ou par la page des définitions.
- Le domaine reste dans
config/: aucun nom de modèle, de type ni d’aspect n’apparaît dans le code. - Un modèle peut devenir invalide quand un aspect évolue (champ renommé,
enumréduite). Il est alors ignoré et signalé, et il disparaît de la palette, même si une version précédente était chargée : au rechargement, les modèles conservés sont revalidés contre les aspects actuels (amendé après revue), sans effet sur les nœuds déjà créés. Inversement, un champ devenu requis ne casse pas un modèle, puisque les champs requis ne sont pas exigés. - Un modèle chargé avec succès ne garantit pas une création réussie : les tags non canoniques et les champs requis ne sont contrôlés qu’à l’enregistrement. L’erreur s’affiche alors dans le formulaire, sur le champ concerné, et le test d’intégration couvre les exemples de
config/. node.createdportetemplate. L’écran d’historique (jalon 3) pourra afficher « créé depuis le modèle … », l’export peut l’ignorer et les règles pourront s’en servir.- Un bean
Clockexiste désormais. Les dates des modèles dépendent du fuseau du client. Sanszone(scripts), elles dépendent du fuseau du conteneur : l’étape de déploiement devra fixerTZ=Europe/Brusselsdansdocker-compose. - Si le jalon 3 étend les variables (SpEL,
{{title}}), la règle du corps devra rester « seules les variables reconnues sont remplacées ». Sinon, un{{…}}écrit aujourd’hui dans un corps de modèle changerait de sens. - L’API des définitions (V2) devra écrire
config/templates/, et sa validation à blanc couvrira ces fichiers. - Le bundle initial ne change pas : l’éditeur et la palette sont déjà chargés à la demande.
templates$ne fait qu’un appel, mis en cache.
Reporté
- Défauts dynamiques par aspect (« templates de valeurs par aspect », cahier, ligne « Valeurs par défaut »). Les valeurs
defaultstatiques existent déjà (création, changement de type, Formly), et les valeurs par aspect d’un modèle couvrent le besoin au cas par cas. Une annotation de schéma (x-default: "{{today}}", résolue parTemplateVariablesdansapplyDefaultset dans le formulaire) fera l’objet d’une ligne de backlog distincte. - Création en un appel (
POST /api/nodes/from-template/{name}ou option decreateNodedans les actions et règles du jalon 3), et actions globales « Nouvelle facture » de dashboard qui ouvrent un modèle (jalons 2 et 3). - Parent et références dans un modèle, quand un identifiant de nœud lisible existera (alias, chemin) ; parent relatif au nœud courant (« créer un compte-rendu sous ce projet »).
- Variables supplémentaires : formatage des dates (
{{today:dd/MM/yyyy}}), années (y), heure seule, variables du contexte ({{parent.title}}), et SpEL au jalon 3. - Menu « Nouveau » de la barre d’outils avec les modèles, sélecteur de modèle dans le formulaire, modèles proposés par type (
suggestedTemplates), modèle par défaut d’un type. - Création depuis un modèle directement dans l’inbox (capture typée) et note du jour générée depuis un modèle (V2).
- Propriété serveur
app.zone, qui servirait de fuseau par défaut et à l’évaluation des règles (jalon 3). - Libellé « créé depuis le modèle » dans l’écran d’historique (jalon 3).
Questions ouvertes tranchées (mode autonome)
- Emplacement et format :
config/templates/<name>.yaml, clés platesname,label,icon,type,title,aspects,tagsetbody, validées partemplate.schema.json. - Parent et
node-ref: non admis au jalon 1 (identifiants non portables). - Variables :
{{today}}et{{now}}, décalage± N d|w|m, résolues dans le fuseau du client ; sans SpEL. Contrôle strict dans le titre et les valeurs d’aspects, tolérant dans le corps. - Validation au chargement : type et aspects chargés, champs déclarés, pas de
node-ref, variables reconnues, conformité au schéma de l’aspect sansrequired. Les tags et le corps ne sont contrôlés que par le méta-schéma. - Application : préremplissage du formulaire à partir d’un brouillon serveur (
GET /api/definitions/templates/{name}/draft) ;POST /api/nodesinchangé, à la provenance près (template, non vérifiée, recopiée dansnode.created). - Exposition :
GET /api/definitions/templates(brut),DefinitionsStatus.templates. - Front :
/nodes/new?template=(les paramètres explicites l’emportent), un groupe « Modèles » dans la palette, une section « Modèles » avec un bouton « Créer » dans la page des définitions. Le formulaire prérempli n’est pas considéré comme modifié. - Exemples :
facture-papier,compte-rendu-reunionettache-semaine, couverts parExampleDefinitionsTestet par un test d’intégration qui enregistre chacun d’eux. - Ligne « Valeurs par défaut » du cahier : défauts statiques déjà en place, valeurs par aspect via les modèles, défauts dynamiques reportés à une ligne de backlog distincte.
- Migration : aucune. Événements : aucun nouveau, clé
templateajoutée ànode.created.