Second Brain documentation

ADR 0016 – Modèles de nœud en YAML (`config/templates/`)

Accepté (2026-10-15, mode autonome)

← Toutes les décisions

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 lit config/aspects, config/types et config/links, et valide chaque fichier contre definitions/meta/<kind>.schema.json. Il impose name égal au nom du fichier. Il vérifie ensuite les références croisées : aspects d’un type, contraintes source/target d’un lien, x-target d’un champ référence. Un fichier invalide produit un LoadIssue et ne fait pas tomber l’application. DefinitionRegistry.keepPreviousOnError garde au rechargement la version valide précédente d’une définition devenue invalide. DefinitionsController expose /aspects, /types, /links et /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 skill definitions-format. Les dossiers config/views et config/dashboards existent 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 : actions et rules sont chargées « en structure libre » pour le jalon 3, et aucune classe SpEL n’existe. Les variables today, now et les durées 7d, 2w, 1m y sont prévues.

  • Création (NodeService.create) :

    • le type est résolu (400 type s’il est inconnu) ;
    • un aspect vide est ajouté par aspect obligatoire, puis fusionné avec les données fournies ;
    • chaque aspect passe par prepareAspect : applyDefaults (valeurs default de 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 paquet node ;
    • le tout est écrit, avec l’événement node.created.

    CreateNodeRequest porte title, type, body, parentId, aspects, tags et inbox.

  • Front :

    • /nodes/new accepte ?type= (selectType ajoute les sections obligatoires), ?parent= et ?title= ;
    • en création, les valeurs default sont 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 ;
    • DefinitionsStore met 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 Clock ni ZoneId dans le code). L’image Docker tourne par défaut en UTC.

Trois choix sont difficiles à défaire :

  1. Le format du fichier : les utilisateurs l’écrivent à la main et l’API des définitions (V2) l’écrira.
  2. La syntaxe des variables : les modèles écrits aujourd’hui doivent rester valides quand SpEL arrivera au jalon 3.
  3. L’endroit où s’applique le modèle : le contrat de POST /api/nodes en 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 de CreateNodeRequest.
  • type est facultatif. Un modèle sans type (journal, note libre) est permis. Les aspects obligatoires du type n’ont pas à figurer dans aspects : le formulaire puis le serveur les ajoutent, comme pour ?type=.
  • aspects associe un nom d’aspect à des valeurs de premier niveau. Un aspect sans valeur (document: ou document: {}) 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 valeurs default du 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 champ format: node-ref. Un UUID écrit dans config/ 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, body et 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, label et icon ne sont pas interprétés.
  • Contrôle strict dans title et 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ées 7d, 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. Sans zone, c’est le fuseau de la JVM (ZoneId.systemDefault(), donc la variable TZ du 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 skill definitions-format le 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);
  1. type, s’il est présent, est un type chargé. Sinon : « Type inconnu : ».
  2. Chaque clé de aspects est un aspect chargé. Sinon : « Aspects inconnus : a, b ». Les contrôles suivants sont faits aspect par aspect et ne concernent que les aspects connus.
  3. 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.
  4. Aucun champ node-ref : « aspects.tache.projet : une référence à un nœud n’est pas admise dans un modèle ».
  5. Variables strictes : TemplateVariables.unknown sur title et 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) ».
  6. 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 required racine. La copie est compilée par JsonSchemas.compile à chaque chargement. Les required imbriqués restent en vigueur. Le contrôle repère canal: fax (hors enum), echeance: "demain" (format: date), montant: -1 (minimum), et {{now}} placé dans un champ format: 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);
}
  • Definitions gagne Map<String, TemplateDefinition> templates (copie immuable, comme les autres). Dans DefinitionRegistry :
    • un accesseur Optional<TemplateDefinition> template(String name) ;
    • keepPreviousOnError gagne la branche TEMPLATES ;
    • le journal de chargement compte les modèles.
  • LoadIssue.kind admet templates.
  • Horloge : un bean Clock (Clock.systemUTC()) est déclaré dans une nouvelle classe com.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, today dans 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 de POST /api/nodes s’appliquent sans exception : aspects obligatoires, valeurs par défaut, validation, références, corps, tags et node_event.
  • template dans POST /api/nodes n’applique rien. Il est recopié dans le payload de node.created ("template": "facture-papier", ou null). 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.created gagne la clé template (nom ou null). Une création depuis un modèle produit donc les mêmes événements qu’une création manuelle, et les règles node-created et aspect-added du 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.

  • DefinitionsStore gagne templates$ (shareReplay(1)).

  • core/time-zone.ts : browserTimeZone(): string | undefined, qui renvoie Intl.DateTimeFormat().resolvedOptions().timeZone dans un try/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 forkJoin de chargement gagne draft: !id && template ? this.loadDraft(template) : of(null). loadDraft appelle templateDraft(name, browserTimeZone()). Sur le modèle de loadParent, un 404 affiche « Modèle introuvable : . Formulaire vide. » et continue sans brouillon ; toute autre erreur interrompt le chargement ;

    • application, avant de fixer baseline :

      1. creationDefaults ;
      2. title, body et tags ;
      3. une section par aspect du brouillon (section(name, structuredClone(data), false)), dans l’ordre du fichier. Un aspect inconnu du front rejoint unknownAspects ;
      4. 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() envoie template: this.templateName() ?? undefined. templateName n’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.group gagne 'template', avec l’intitulé de groupe « Modèles ». Les mots-clés sont créer, nouveau, nouvelle, modèle et 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.ts combine types$ et templates$.
  • 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.

Documentation

  • Skill definitions-format :
    • le tableau d’état gagne la ligne « templates | Oui | Jalon 1 (ADR 0016) : préremplissage de /nodes/new?template=, brouillon GET …/templates/{name}/draft » ;
    • nouvelle section « Modèle — config/templates/<name>.yaml » : exemple, sémantique, variables, guillemets YAML, interdiction des node-ref et du parent ;
    • la section « Expressions » précise que les modèles n’acceptent que today et now, sans SpEL.
  • CLAUDE.md : la ligne config/ 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}}, une enum et un tag.
  • compte-rendu-reunion.yaml : type note, titre "Réunion du {{today}}", aspect facultatif document (dateDocument: "{{today}}"), tag reunion, corps en sections (## Participants, ## Points abordés, ## Décisions, ## Actions).
  • tache-semaine.yaml : type tache, tache: { echeance: "{{today + 1w}}", priorite: haute }, sans titre.

Tests

Backend, unitaires :

  • TemplateVariablesTest :
    • today et now (troncature à la seconde, décalage du fuseau) ;
    • décalages d, w et m, 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 par resolve ;
    • resolveDeep : chaînes dans des Map et des List, valeurs non textuelles intactes, original non modifié.
  • TemplateDraftsTest :
    • résolution dans title, body et aspects ;
    • tags, type et label recopiés ;
    • champs absents à null ;
    • la définition n’est pas modifiée.
  • 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 champ format: date, valeur pour un node-ref, variable inconnue dans title ou dans une valeur, clé inconnue (parent), name différent du nom du fichier, titre non cité (title: {{today}}) ;
    • sont acceptés : un champ requis absent, {{ x }} dans body ;
    • 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.
  • DefinitionRegistryTest : un modèle devenu invalide au rechargement garde sa version précédente.
  • ExampleDefinitionsTest : aucun problème de chargement, et templates contient compte-rendu-reunion, facture-papier et tache-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 : dateReception vaut 2026-10-15 et echeance 2026-11-14 ;
    • avec zone=UTC : 2026-10-14 ;
    • zone=Mars/Olympus donne 400 zone ; un modèle inconnu donne 404.
  • Chaque exemple est enregistrable : brouillon, puis POST /api/nodes avec template (en ajoutant facture.montant pour facture-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.created contient "template": "facture-papier".
  • POST /api/nodes avec template: "Pas un nom" donne 400 template. Avec un nom bien formé mais inconnu, la réponse est 201 et le nom est enregistré tel quel.
  • GET /api/definitions/status : templates vaut 3.
  • OpenApiContractIT : présence de /api/definitions/templates et 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, tri fr, 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= appelle templateDraft avec 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

  1. 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 et keepPreviousOnError ;
    • GET /api/definitions/templates et DefinitionsStatus.templates ;
    • les trois exemples de config/templates/ ;
    • les tests unitaires, ExampleDefinitionsTest, et la liste et le statut en intégration ;
    • skill definitions-format et CLAUDE.md.

    Testable seule : GET /api/definitions/status et la liste dans Swagger UI, et un modèle cassé exprès apparaît dans issues.

  2. Brouillon et provenance (feat(definitions): brouillon d'un modèle et provenance à la création) :

    • TimeConfig (Clock), NodeDraft, TemplateDrafts et GET …/templates/{name}/draft ;
    • CreateNodeRequest.template et le payload de node.created ;
    • NodeTemplateIT et OpenApiContractIT.

    Testable seule par Swagger UI : on lit le brouillon, puis on fait le POST.

  3. 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.

Alternatives écartées

  • Appliquer le modèle côté serveur à la création (POST /api/nodes avec template, 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 le POST reste 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 sur TemplateDrafts (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 sur format: 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 default des schémas d’aspect (default: "{{today}}") : default doit être une valeur valide de l’instance (JSON Schema). FormlyJsonschema l’afficherait brute, et applyDefaults l’é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 }, ou values:) : un niveau d’indentation de plus à écrire à la main, sans conflit de noms à éviter.
  • Modèle déclaré dans le fichier de type (templates: sous config/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 un name, suit la règle de l’ADR 0002.
  • Accepter parent et les node-ref par 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.Tags public : cela crée une dépendance definitions → node, alors que node dépend déjà de definitions. 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 de config/ 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ètre zone donne 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é, enum ré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.created porte template. 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 Clock existe désormais. Les dates des modèles dépendent du fuseau du client. Sans zone (scripts), elles dépendent du fuseau du conteneur : l’étape de déploiement devra fixer TZ=Europe/Brussels dans docker-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 default statiques 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 par TemplateVariables dans applyDefaults et 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 de createNode dans 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 plates name, label, icon, type, title, aspects, tags et body, validées par template.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 sans required. 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/nodes inchangé, à la provenance près (template, non vérifiée, recopiée dans node.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-reunion et tache-semaine, couverts par ExampleDefinitionsTest et 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é template ajoutée à node.created.