Second Brain documentation

ADR 0015 – Changement de type d'un nœud existant (« Classer comme… »)

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

← Toutes les décisions

ADR 0015 – Changement de type d’un nœud existant (« Classer comme… »)

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 nœud existant change de type par PUT /api/nodes/{id}/type, avec { "type": "<nom>" | null, "aspects": { … } }. Le même endpoint couvre trois cas : pas de type vers un type, un type vers un autre, un type vers aucun type (type absent, nul ou vide). Les aspects obligatoires du nouveau type que le nœud ne porte pas encore sont ajoutés dans la même transaction. Leurs données peuvent être fournies dans la requête ; les valeurs default du schéma complètent, puis le JSON Schema valide. Si un champ requis manque, la réponse est 400 et rien n’est écrit. Tous les aspects déjà portés sont conservés, ceux de l’ancien type compris : ils deviennent simplement retirables. Ils ne sont pas revalidés. Un type inconnu donne 400. Événements : type.changed {before: {type}, after: {type}, addedAspects}, puis un aspect.added par aspect ajouté, avec "reason": "type.mandatory". Aucun effet sur l’inbox côté serveur. Côté front, un dialogue unique « Classer comme… » sert l’éditeur et la page inbox. Il propose le choix du type et un formulaire pour chaque aspect obligatoire manquant. Une case « Sortir de l’inbox » apparaît si le nœud y est ; elle est cochée par défaut et déclenche un DELETE …/inbox explicite. Pas de migration. Format YAML et méta-schémas inchangés.

Contexte

L’ADR 0014 a reporté le changement de type, qui est pourtant nécessaire pour trier l’inbox. On capture un titre seul, sans type, puis on doit pouvoir dire « c’est une facture ». La ligne de backlog correspondante est « Changement de type d’un nœud existant (API et éditeur), nécessaire pour trier l’inbox ».

État du code :

  • node.type est une colonne text nullable, indexée (node_type_idx, V1). Elle n’est écrite qu’à la création (NodeRepository.insertNode). PATCH /api/nodes/{id} ne touche que le titre et le corps.
  • NodeService.create résout le type (400 type « Type inconnu : … »). Il ajoute un aspect vide par aspect obligatoire, fusionne les données fournies, puis traite chaque aspect : applyDefaults (premier niveau), canonicalizeRefs, validateAspect (JSON Schema), et referenceErrors pour les champs node-ref. Les erreurs sont agrégées en un seul 400 par chemin (aspects.<a>, aspects.<a>.<champ>). aspect_text (ADR 0013) est calculé sur l’ensemble des aspects.
  • putAspect ajoute ou remplace un aspect. L’événement est aspect.added {after} ou aspect.updated {before, after} (champs modifiés), et touch(id, aspectText) suit. removeAspect refuse un aspect obligatoire du type courant (400 aspect). Les obligations sont lues dans config/ au moment de l’appel : changer node.type suffit donc à libérer les aspects de l’ancien type.
  • config/types/ : tache ([tache]), projet ([projet]), facture-fournisseur ([document, facture, courrier]) et note (aucun aspect obligatoire, suggestedAspects: [document]). facture exige montant et echeance, sans valeur par défaut. tache.statut, tache.priorite, projet.statut, courrier.canal et facture.payee ont une valeur par défaut. tache.projet est un node-ref.
  • Les contraintes des liens (source, target) et des x-target peuvent porter sur des types (NodeFilter.types). Elles sont contrôlées à l’écriture du lien ou de la référence, jamais ensuite. Les définitions de config/links/ ne contraignent aujourd’hui que des aspects.
  • L’inbox (ADR 0014) est un état explicite, node.inbox_at. On n’en sort que par DELETE /api/nodes/{id}/inbox. Une sortie automatique, côté serveur comme côté front, y a été écartée.
  • Dans l’éditeur (node-editor-page.ts), le type n’est choisi qu’en création (mat-select, selectType ajoute les sections obligatoires). Un nœud existant affiche « Type : … » en lecture seule. « Enregistrer » enchaîne des requêtes sans atomicité (replaceTags, update, removeAspect, putAspect). Déplacer et sortir de l’inbox sont des écritures immédiates qui préservent la saisie en cours.
  • La page inbox liste des NodeSummary, qui portent type, aspects (noms) et inboxAt, avec un bouton « Classé » par élément.

Trois choix sont difficiles à défaire. D’abord, le sort des aspects de l’ancien type : une suppression perd des données. Ensuite, la forme de l’événement, que liront les règles du jalon 3. Enfin, le contrat de l’endpoint, que le client généré et le futur moteur d’actions consommeront.

Décision

Sémantique

  • Trois transitions, un seul endpoint : null → T, T → U et T → null. Retirer le type suit le précédent de PUT …/parent (ADR 0007), où parentId nul signifie la racine : type absent, nul ou fait d’espaces signifie « sans type ». C’est la même tolérance que CreateNodeRequest.type.

  • Aspects ajoutés : ce sont les mandatoryAspects du nouveau type que le nœud ne porte pas, dans l’ordre de la définition. Pour chacun :

    1. la donnée vient de request.aspects[a], ou {} si elle est absente ou nulle ;
    2. applyDefaults puis canonicalizeRefs ;
    3. validateAspect ;
    4. referenceErrors(id, {}, …) pour les aspects conformes. selfId vaut l’identifiant du nœud, ce qui interdit une référence vers soi.

    Les erreurs sont agrégées comme à la création. Au moindre problème, la réponse est 400 et rien n’est écrit : ni type, ni aspect, ni événement.

  • Données fournies pour un aspect qui n’est pas à ajouter :

    • aspect que le nouveau type n’impose pas (ou n’importe quel aspect si l’on retire le type) : 400 sur aspects.<a>, avec le message « Aspect non obligatoire pour ce type : ajoutez-le depuis l’éditeur ». On n’ignore pas en silence une erreur du client ;
    • aspect imposé par le nouveau type mais déjà porté : les données sont ignorées et l’aspect existant n’est pas modifié. C’est ce qui rend la requête rejouable : un double clic ou une nouvelle tentative réseau renvoie 200, sans écraser une saisie faite entre-temps. Pour modifier un aspect porté, on passe par PUT …/aspects/{a}.
  • Aspects conservés : tous ceux que le nœud porte, y compris les obligatoires de l’ancien type et ceux dont la définition a disparu. Le changement est réversible : revenir à l’ancien type ne demande aucune saisie. Ces aspects ne sont pas revalidés (valider à l’écriture, tolérer à la lecture). Les aspects obligatoires du nouveau type que le nœud porte déjà sont acceptés tels quels.

  • Type inconnu (absent de config/types/, ou fichier invalide et ignoré) : 400 sur type, avec « Type inconnu : », comme à la création. Si le type actuel du nœud a disparu de config/, on peut quand même en changer. Son nom brut apparaît dans before.type.

  • Idempotence : si le type est inchangé et qu’aucun aspect n’est à ajouter, la réponse est 200, sans événement ni mise à jour. Si le type est inchangé mais qu’un aspect obligatoire manque, par exemple parce que config/ a ajouté un aspect obligatoire après la création, la requête complète le nœud. Elle produit alors des aspect.added, met à jour updated_at, et n’émet pas de type.changed. Le dialogue du front s’en sert sans traitement particulier.

  • Liens et références existants : ils ne sont pas recontrôlés. Un lien typé ou un node-ref dont la contrainte types désignait l’ancien type reste en place, sur le modèle de l’ADR 0005 (une référence inchangée n’est pas recontrôlée) et de removeAspect, qui ne contrôle pas non plus les liens. Aucun aspect n’étant retiré, les contraintes d’aspects restent satisfaites.

  • Inbox : inbox_at n’est pas touché (ADR 0014 : on ne sort de l’inbox que de façon explicite). Le front la propose explicitement (voir plus bas).

  • Corbeille : 404, comme pour toute écriture (find exclut deleted_at).

Événements

Ils sont écrits dans la transaction, dans cet ordre :

  1. type.changed, avec aspect nul, seulement si le type change :

    { "before": { "type": "tache" }, "after": { "type": "facture-fournisseur" },
      "addedAspects": ["document", "facture", "courrier"] }

    before.type et after.type valent null pour « sans type ». addedAspects est toujours présent, éventuellement vide.

  2. Un aspect.added par aspect ajouté, avec aspect = <nom> et la même forme que putAspect, plus la raison :

    { "after": { "montant": 42.5, "echeance": "2026-11-01", "payee": false }, "reason": "type.mandatory" }

    La constante est NodeService.TYPE_MANDATORY_REASON = "type.mandatory", sur le modèle de WikiLinkService.RENAME_REASON. Les règles du jalon 3 qui se déclenchent sur aspect-added (celle de facture.yaml crée une tâche de paiement) réagissent donc de la même façon, que l’aspect vienne de l’éditeur ou d’un changement de type.

Il n’y a pas d’événement node.updated. Le seul UPDATE de node écrit type, aspect_text et updated_at.

Migration

Aucune. node.type (texte nullable) et node_type_idx existent depuis V1, et node_event.event_type est libre.

API

Méthode Chemin Corps Réponse
PUT /api/nodes/{id}/type ChangeTypeRequest 200 NodeView ; 400 type, aspects.<a>, aspects.<a>.<champ> ; 404 si le nœud est absent ou en corbeille
// NodeDtos
/**
 * @param type    nouveau type (nom d'un fichier de config/types/) ; absent, nul ou vide = sans type
 * @param aspects données des aspects obligatoires du nouveau type que le nœud ne porte pas encore ;
 *                les valeurs default du schéma complètent ; les données d'un aspect déjà porté sont ignorées
 */
public record ChangeTypeRequest(String type, Map<String, Map<String, Object>> aspects) { }

// NodeController
@PutMapping("/{id}/type")
@Operation(summary = "Change le type du nœud (« Classer comme… ») : ajoute les aspects obligatoires manquants du "
        + "nouveau type (données fournies, puis valeurs par défaut, puis validation) et conserve tous les aspects "
        + "portés ; type absent = sans type ; idempotent")
public NodeView changeType(@PathVariable UUID id, @RequestBody ChangeTypeRequest request);

PUT exprime l’état visé : le corps décrit le type voulu et la réponse est la même à chaque appel.

Moteur (paquet node)

On isole une règle pure, testée unitairement, sur le modèle de Hierarchy et LinkConstraints :

/** Changement de type d'un nœud (ADR 0015) : plan des écritures, sans accès BD. */
record TypeChange(String before, String after, List<String> addedAspects) {

    /**
     * @param currentType type stocké (peut être absent de config/)
     * @param carried     aspects portés par le nœud
     * @param target      nouveau type résolu ; null = sans type
     */
    static TypeChange plan(String currentType, Set<String> carried, TypeDefinition target);

    boolean typeChanged();   // !Objects.equals(before, after)
    boolean isNoop();        // !typeChanged() && addedAspects.isEmpty()

    /**
     * Aspects fournis que le nouveau type n'impose pas : chemin aspects.<a> -> message. Les aspects imposés mais
     * déjà portés ne sont pas des erreurs (données ignorées).
     */
    static Map<String, List<String>> unexpected(Set<String> provided, TypeDefinition target);
}
// NodeService
public static final String TYPE_MANDATORY_REASON = "type.mandatory";

/**
 * Lock, find (404), résolution du type (400), plan, préparation et validation des aspects ajoutés (400 agrégé,
 * rien d'écrit), puis, sauf no-op : updateType, insertAspect par aspect ajouté, type.changed, aspect.added.
 */
@Transactional
public NodeView changeType(UUID id, String type, Map<String, Map<String, Object>> aspects);

// NodeRepository
/** Écrit type, aspect_text (ADR 0013) et updated_at dans le même UPDATE. */
void updateType(UUID id, String type, String aspectText);
  • La préparation d’un aspect (applyDefaults, canonicalizeRefs, validateAspect) est commune à create et changeType. Elle est extraite dans une méthode privée, prepareAspect(String name, Map<String, Object> data, Map<String, List<String>> errors), sans changer le comportement de create.
  • aspect_text est recalculé sur les aspects portés, plus ceux ajoutés. aspectText(current, changed, replacement) gagne une surcharge qui accepte plusieurs aspects ajoutés.
  • Si le type est inchangé mais qu’il manque des aspects, on appelle touch(id, aspectText) au lieu de updateType.
  • Seul le verrou du nœud (nodes.lock(id)) est pris : ni index wiki, ni hiérarchie.

Front : dialogue « Classer comme… »

Fonctions pures, dans core/node-type.ts :

/** Aspects obligatoires de `type` que le nœud ne porte pas, dans l'ordre de la définition ; [] si type est null. */
export function missingAspects(type: TypeDefinition | null | undefined, carried: readonly string[]): string[];
/** Aspects portés qui ne sont pas obligatoires pour `type` : conservés, et retirables ensuite. */
export function keptAspects(type: TypeDefinition | null | undefined, carried: readonly string[]): string[];

La fonction clean (valeurs vides retirées) sort de node-editor-page.ts pour devenir cleanAspectModel dans core/aspect-model.ts, partagée par l’éditeur et le dialogue. labelForPath reconnaît désormais type (« Type »).

Composant shared/node-type/node-type-dialog.ts, NodeTypeDialog (MatDialog) :

export interface NodeTypeDialogData {
  id: string; title: string; type: string | null; aspects: readonly string[]; inboxAt?: string;
}
export interface NodeTypeDialogResult { node: NodeView; leftInbox: boolean; inboxError?: string[] }
  • Les données d’entrée tiennent dans un NodeSummary comme dans un NodeView : la page inbox n’a pas à relire le nœud.

  • Le titre est « Classer « titre » comme… », ou « Changer le type de « titre » » si le nœud a déjà un type.

  • Choix du type : un mat-radio-group vertical (aria-label « Type »), avec l’icône et le libellé de chaque type de DefinitionsStore.types$ (tri fr). L’option « Aucun type » ne s’affiche que si le nœud a un type. Le type courant est présélectionné et suivi de « (actuel) ». Un type actuel absent de config/ s’affiche par son nom. Une liste de boutons radio montre tout d’un coup d’œil et se sélectionne d’un seul toucher sur mobile, sans panneau déroulant.

  • Champs à compléter : un bloc par aspect de missingAspects(choix, data.aspects), avec l’icône, le libellé et un formly-form construit par AspectFormBuilder.fields. FormlyJsonschema prérenseigne les valeurs default. Le formState vaut { references: {}, nodeId: id } pour le champ node-ref. S’il n’y a rien à compléter : « Aucun champ à compléter. »

  • Rappel : si keptAspects n’est pas vide, une ligne indique « Conservés : Tâche, Document. Vous pourrez les retirer ensuite. »

  • Inbox : si inboxAt est présent, une case « Sortir de l’inbox (classé) », cochée par défaut. La sortie reste une décision visible de l’utilisateur, dans l’esprit de l’ADR 0014, et non une règle cachée.

  • Bouton « Classer » :

    • il est désactivé pendant l’envoi, et aussi quand le type choisi est le type courant sans aspect à compléter ;
    • il commence par une validation locale (markAllAsTouched, sans envoi si un formulaire est invalide) ;
    • il envoie ensuite changeType(id, { type: choix ?? undefined, aspects }), avec les seuls aspects manquants passés par cleanAspectModel ;
    • si la case est cochée, InboxStore.classify(id) suit, ce qui rafraîchit le compteur.

    En cas de 400, les messages s’affichent dans le dialogue (role="alert", describeApiError avec labelForPath), et le dialogue reste ouvert avec la saisie. Si le changement de type réussit mais que la sortie de l’inbox échoue, le dialogue se ferme avec inboxError : le type est enregistré et l’appelant affiche l’erreur.

  • Mise en page : autoFocus sur le groupe de boutons radio ; largeur min(560px, 100vw - 32px) ; plein écran sur Breakpoints.XSmall, comme la palette.

  • Annulation : aucun « Annuler » dans le snackbar. Défaire un changement de type demanderait plusieurs écritures (rétablir le type, retirer les aspects ajoutés). On revient en arrière en rouvrant le dialogue, sans perte, puisque rien n’a été retiré.

Front : intégrations

  • Éditeur (nœud existant uniquement ; la création garde son mat-select) :
    • la ligne « Type : … » devient « Type : <libellé> » ou « Sans type », suivie d’un bouton mat-stroked-button. Son libellé est « Classer comme… » sans type, « Changer le type » sinon ;
    • au clic, si hasUnsavedChanges(), le snackbar « Enregistrez d’abord vos modifications » s’affiche et le dialogue ne s’ouvre pas. Fusionner un changement de type avec une saisie en cours (sections non enregistrées, aspects retirés localement mais imposés par le nouveau type) serait la seule partie délicate de la tranche. Sans modification en cours, le rechargement est trivial ;
    • en cas de succès : loadNode(result.node), baseline = stateKey() et savedCount incrémenté, pour que le panneau des liens relise nodeType. Les sections de l’ancien type deviennent retirables, celles du nouveau ne le sont pas. Le snackbar affiche « Classé comme <libellé> » ou « Type retiré ». inboxError est affiché dans errors.
  • Page inbox : chaque élément gagne un bouton « Classer comme… » (icône category, aria-label « Classer « titre » comme… »), avant « Classé » ; en dessous de 600 px, il est réduit à son icône. Au retour du dialogue :
    • si leftInbox est vrai, l’élément quitte la liste et le snackbar affiche « « titre » classé comme <libellé> », avec l’action « Ouvrir » vers /nodes/:id ;
    • sinon, l’élément est mis à jour sur place (type, aspects = clés de node.aspects) ;
    • le focus suit le même traitement que pour « Classé ».
  • La palette n’a pas de nœud courant : elle ne propose pas encore « Classer comme… » (voir Reporté).

Documentation

  • Skill definitions-format, ligne « types » du tableau d’état : « Oui (aspects obligatoires à la création et au changement de type, ADR 0015) ». Commentaire de mandatoryAspects : « ajoutés à la création et au changement de type ; non retirables tant que le type du nœud les impose ».
  • Backlog : case cochée, avec le résumé et les éléments reportés.

Tests

Backend, unitaires (TypeChangeTest) :

  • plan :
    • null → tache : aspect ajouté [tache] ;
    • tache → facture-fournisseur avec document déjà porté : aspects ajoutés [facture, courrier], dans l’ordre de la définition ;
    • T → null : aucun aspect ajouté, typeChanged vrai ;
    • type identique, rien qui manque : isNoop ;
    • type identique avec un aspect manquant : pas de typeChanged, aspect ajouté ;
    • type actuel inconnu de config/ → autre type.
  • unexpected : aspect non imposé, retrait du type avec des aspects fournis, aspect imposé mais déjà porté (pas d’erreur).

Backend, intégration (NodeTypeIT, Testcontainers) :

  • Sans type → tache :
    • tache est ajouté avec ses valeurs par défaut (statut: a-faire, priorite: normale) ;
    • événements type.changed (before.type nul, addedAspects: ["tache"]), puis aspect.added avec reason: "type.mandatory" ;
    • updated_at est modifié ;
    • GET /api/nodes?type=tache trouve le nœud.
  • Sans type → facture-fournisseur :
    • sans données : 400 sur aspects.facture, et ni le type, ni les aspects, ni les événements ne changent ;
    • avec montant et echeance : trois aspects ajoutés, courrier.canal = poste ;
    • facture.communication est trouvé par q (aspect_text).
  • tache → projet : tache est conservé avec ses données, projet est ajouté. DELETE …/aspects/tache répond ensuite 200 ; DELETE …/aspects/projet répond 400.
  • tache → sans type : aspects conservés, type.changed avec after.type nul ; tache devient retirable.
  • Retour à l’ancien type : aucune donnée à fournir, aucun aspect ajouté, seulement un type.changed.
  • Idempotence :
    • un second PUT identique, données comprises, répond 200, sans événement, sans changer updated_at, et sans écraser l’aspect ;
    • même type avec un aspect obligatoire supprimé en SQL (données anciennes) : aspect.added, pas de type.changed.
  • Erreurs :
    • type inconnu : 400 sur type ;
    • données pour un aspect non imposé : 400 sur aspects.<a> ;
    • montant: -1 : 400 sur aspects.facture ;
    • tache.projet vers un nœud inconnu ou vers soi : 400 sur aspects.tache.projet. Un UUID en majuscules est stocké en minuscules ;
    • 404 pour un nœud inconnu ou en corbeille.
  • Inbox : un nœud de l’inbox y reste après le changement de type (inbox_at inchangé).
  • Contrat : OpenApiContractIT vérifie la présence de /api/nodes/{id}/type.

Front (Vitest) :

  • node-type.spec.ts : missingAspects (ordre, aspects déjà portés, type nul), keptAspects.
  • node-type-dialog.spec.ts :
    • affichage : types et « (actuel) », « Aucun type » seulement si le nœud a un type, formulaires des seuls aspects manquants avec leurs valeurs par défaut, ligne « Conservés » ;
    • case de l’inbox : absente hors inbox, cochée par défaut sinon ;
    • envoi : un formulaire invalide n’envoie rien. changeType reçoit les seuls aspects manquants, nettoyés. Avec la case cochée, DELETE …/inbox suit ; sans elle, non ;
    • erreurs : un 400 s’affiche avec les libellés et le dialogue reste ouvert. Un échec de la sortie de l’inbox ferme le dialogue avec inboxError ;
    • « Classer » est désactivé sur le type courant quand rien ne manque.
  • node-editor-page-type.spec.ts :
    • libellé du bouton selon le type ;
    • modifications en cours : snackbar, et dialogue non ouvert ;
    • après succès : rechargement, et section de l’ancien type retirable, celle du nouveau non ;
    • la page n’est pas marquée modifiée.
  • inbox-page.spec.ts : bouton « Classer comme… », élément retiré et snackbar « Ouvrir » si leftInbox, élément mis à jour sinon.
  • api-problem.spec.ts : labelForPath('type') vaut « Type ».

Découpage

  1. Backend (feat(nodes): changement de type d'un nœud existant) :

    • TypeChange et son test ;
    • prepareAspect extrait de create (les tests existants servent de filet) ;
    • NodeRepository.updateType, NodeService.changeType et ses événements ;
    • ChangeTypeRequest, endpoint et description OpenAPI ;
    • NodeTypeIT et OpenApiContractIT.

    Testable seule par Swagger UI.

  2. Front (feat(nodes): « Classer comme… » dans l'éditeur et l'inbox) :

    • client régénéré (skill api-contract) ;
    • core/node-type.ts, core/aspect-model.ts, labelForPath('type') ;
    • NodeTypeDialog ;
    • intégrations dans l’éditeur et dans l’inbox ;
    • tests ;
    • skill definitions-format et backlog.

Alternatives écartées

  • Retirer les aspects obligatoires de l’ancien type, systématiquement ou s’ils sont vides : perte de données silencieuse et irréversible, et « vide » est mal défini à cause des valeurs par défaut. Le nœud reste lisible avec des aspects en plus, qu’on retire d’un clic.
  • Poser les valeurs par défaut et accepter un aspect non conforme (champs requis manquants), à compléter plus tard : contraire à « valider à l’écriture ». Les vues du jalon 2 (« factures à payer ») verraient des factures sans montant.
  • Refuser sans permettre de fournir les données, en laissant le client enchaîner PUT …/aspects/{a} puis PUT …/type : plusieurs allers-retours sans atomicité. Un échec au milieu laisse les aspects ajoutés et le type inchangé.
  • Accepter dans la même requête des données pour n’importe quel aspect (modifier les aspects portés, en ajouter d’autres) : élargit le contrat de l’endpoint, alors que putAspect existe déjà. Les données d’un aspect porté sont ignorées pour la seule raison de rendre la requête rejouable.
  • Ignorer en silence les aspects non imposés : masquerait une erreur du client, par exemple un formulaire construit pour un autre type.
  • Changer le type par PATCH /api/nodes/{id} : le PATCH exige title et écrase body (ADR 0006, 0007, 0014).
  • DELETE /api/nodes/{id}/type pour retirer le type : un second endpoint pour un cas que type: null exprime déjà, comme parentId: null pour la racine.
  • Un seul événement type.changed contenant les données des aspects ajoutés, sans aspect.added : les règles aspect-added du jalon 3 manqueraient ces ajouts, ou devraient lire deux formes d’événement.
  • node.updated avec {before: {type}, after: {type}} : node.updated décrit le titre et le corps. Un événement dédié est plus simple à filtrer pour l’historique et pour un futur déclencheur type-changed.
  • Sortie de l’inbox par le serveur au changement de type, automatique ou par une option inbox dans la requête : l’ADR 0014 réserve la sortie à un geste explicite, et l’option mêlerait deux états dans un endpoint. La case du dialogue est explicite et visible. Si la seconde requête échoue, l’état reste cohérent : le nœud est typé et toujours dans l’inbox.
  • Changer le type par le mat-select de l’éditeur, appliqué à « Enregistrer » : ajoute une étape à une séquence déjà non atomique, oblige à fusionner les sections locales, et ne sert pas la page inbox, qui n’a pas d’éditeur. Un dialogue unique sert les deux écrans.
  • Ouvrir le dialogue malgré des modifications en cours et fusionner les états : seule partie délicate de la tranche (aspects retirés localement mais devenus obligatoires, sections non enregistrées). Demander d’enregistrer d’abord est sûr, et rare dans le tri de l’inbox.
  • mat-select dans le dialogue : un panneau déroulant de plus à ouvrir sur mobile. Avec quelques types, les boutons radio sont plus rapides.
  • « Annuler » dans le snackbar : il faudrait rétablir le type et retirer les aspects ajoutés, au risque de défaire une saisie faite entre-temps. Rouvrir le dialogue suffit, sans perte.
  • Contrôler les liens et références existants au changement de type (refuser ou signaler ceux dont la contrainte types n’est plus satisfaite) : contraire à la tolérance de l’ADR 0005, et sans cas réel aujourd’hui (config/links/ ne contraint que des aspects).

Conséquences

  • Trier l’inbox tient en quelques gestes : « Classer comme… », un type, éventuellement deux champs, puis « Classer », et le nœud sort de l’inbox. Sur mobile, le dialogue est plein écran.
  • Un nœud peut porter des aspects qui ne correspondent plus à son type. C’est voulu (modèle Alfresco : le type impose un minimum, pas un maximum), et l’éditeur les montre retirables.
  • Le type d’un nœud n’est plus figé : les vues du jalon 2 filtrées par type et les contraintes types des liens et des x-target portent sur le type actuel. Les liens créés sous un ancien type sont conservés. Un signalement des incohérences pourra venir avec l’écran de santé des données.
  • Nouveaux événements : type.changed, et aspect.added avec reason. L’écran d’historique (jalon 3) devra les libeller ; les règles pourront s’y brancher. Classer un nœud comme facture déclenchera la règle aspect-added de facture.yaml, comme un ajout manuel de l’aspect.
  • Ajouter un aspect obligatoire à un type dans config/ ne modifie pas les nœuds existants. Le dialogue, rouvert sur le type courant, permet de compléter un nœud. Le service ne le fait pas automatiquement.
  • updated_at change : le nœud classé remonte en tête de la liste des nœuds.
  • L’export (tranche dédiée) n’est pas affecté : le type est déjà un attribut du nœud.

Reporté

  • Retrait optionnel des aspects de l’ancien type dans le même geste (removeAspects dans la requête, cases à cocher dans le dialogue).
  • Proposer les suggestedAspects du type dans le dialogue et dans l’éditeur ; la clé est chargée mais ne sert pas encore.
  • « Classer comme… » dans la palette (commande contextuelle sur le nœud ouvert, puis « Classer comme » direct), et traitement en lot dans l’inbox.
  • « Annuler » un changement de type.
  • Bandeau de l’éditeur « Aspects obligatoires manquants » pour les nœuds dont le type a gagné un aspect obligatoire dans config/.
  • Signalement des liens et références dont la contrainte types n’est plus satisfaite (santé des données).
  • Libellés de type.changed dans l’historique, et déclencheur type-changed des règles (jalon 3).
  • Ouvrir le dialogue malgré des modifications en cours dans l’éditeur, en fusionnant les états.

Questions ouvertes tranchées (mode autonome)

  • Transitions : un seul endpoint PUT /api/nodes/{id}/type pour null → T, T → U et T → null (type absent, nul ou vide).
  • Aspects obligatoires du nouveau type : données dans la même requête, valeurs par défaut en complément, validation JSON Schema et des références, puis 400 sans écriture si un champ requis manque. L’opération est atomique.
  • Données pour un aspect non imposé : 400. Données pour un aspect imposé mais déjà porté : ignorées, pour que la requête reste rejouable.
  • Aspects de l’ancien type : conservés, sans revalidation, et retirables ensuite.
  • Type inconnu : 400 type. Changer un type devenu inconnu est permis.
  • Même type : sans effet, sauf pour compléter un aspect obligatoire manquant.
  • Événements : type.changed {before, after, addedAspects}, puis aspect.added avec reason: "type.mandatory".
  • Inbox : aucun effet côté serveur. Le dialogue propose une case « Sortir de l’inbox », cochée par défaut, qui appelle explicitement DELETE …/inbox.
  • Liens et références existants : non recontrôlés.
  • UX : un dialogue unique (boutons radio, formulaires des aspects manquants, rappel des aspects conservés), ouvert depuis l’éditeur (après enregistrement des modifications en cours) et depuis chaque élément de l’inbox. Pas d’« Annuler ».
  • Migration : aucune. Format YAML et méta-schémas : inchangés.