ADR 0015 – Changement de type d'un nœud existant (« Classer comme… »)
Accepté (2026-10-15, mode autonome)
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.typeest une colonnetextnullable, 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.createrésout le type (400type« 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), etreferenceErrorspour les champsnode-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.putAspectajoute ou remplace un aspect. L’événement estaspect.added{after}ouaspect.updated{before, after}(champs modifiés), ettouch(id, aspectText)suit.removeAspectrefuse un aspect obligatoire du type courant (400aspect). Les obligations sont lues dansconfig/au moment de l’appel : changernode.typesuffit donc à libérer les aspects de l’ancien type.config/types/:tache([tache]),projet([projet]),facture-fournisseur([document, facture, courrier]) etnote(aucun aspect obligatoire,suggestedAspects: [document]).factureexigemontantetecheance, sans valeur par défaut.tache.statut,tache.priorite,projet.statut,courrier.canaletfacture.payeeont une valeur par défaut.tache.projetest unnode-ref.- Les contraintes des liens (
source,target) et desx-targetpeuvent 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 deconfig/links/ne contraignent aujourd’hui que des aspects. - L’inbox (ADR 0014) est un état explicite,
node.inbox_at. On n’en sort que parDELETE /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,selectTypeajoute 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 portenttype,aspects(noms) etinboxAt, 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 → UetT → null. Retirer le type suit le précédent dePUT …/parent(ADR 0007), oùparentIdnul signifie la racine :typeabsent, nul ou fait d’espaces signifie « sans type ». C’est la même tolérance queCreateNodeRequest.type. -
Aspects ajoutés : ce sont les
mandatoryAspectsdu nouveau type que le nœud ne porte pas, dans l’ordre de la définition. Pour chacun :- la donnée vient de
request.aspects[a], ou{}si elle est absente ou nulle ; applyDefaultspuiscanonicalizeRefs;validateAspect;referenceErrors(id, {}, …)pour les aspects conformes.selfIdvaut 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.
- la donnée vient de
-
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}.
- aspect que le nouveau type n’impose pas (ou n’importe quel aspect si l’on retire le type) : 400 sur
-
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 surtype, 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 dansbefore.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 desaspect.added, met à jourupdated_at, et n’émet pas detype.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-refdont la contraintetypesdé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 deremoveAspect, qui ne contrôle pas non plus les liens. Aucun aspect n’étant retiré, les contraintes d’aspects restent satisfaites. -
Inbox :
inbox_atn’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 (
findexclutdeleted_at).
Événements
Ils sont écrits dans la transaction, dans cet ordre :
-
type.changed, avecaspectnul, seulement si le type change :{ "before": { "type": "tache" }, "after": { "type": "facture-fournisseur" }, "addedAspects": ["document", "facture", "courrier"] }before.typeetafter.typevalentnullpour « sans type ».addedAspectsest toujours présent, éventuellement vide. -
Un
aspect.addedpar aspect ajouté, avecaspect = <nom>et la même forme queputAspect, 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 deWikiLinkService.RENAME_REASON. Les règles du jalon 3 qui se déclenchent suraspect-added(celle defacture.yamlcré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 àcreateetchangeType. 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 decreate. aspect_textest 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 deupdateType. - 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
NodeSummarycomme dans unNodeView: 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-groupvertical (aria-label« Type »), avec l’icône et le libellé de chaque type deDefinitionsStore.types$(trifr). 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 deconfig/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 unformly-formconstruit parAspectFormBuilder.fields. FormlyJsonschema prérenseigne les valeursdefault. LeformStatevaut{ references: {}, nodeId: id }pour le champnode-ref. S’il n’y a rien à compléter : « Aucun champ à compléter. » -
Rappel : si
keptAspectsn’est pas vide, une ligne indique « Conservés : Tâche, Document. Vous pourrez les retirer ensuite. » -
Inbox : si
inboxAtest 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 parcleanAspectModel; - 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",describeApiErroraveclabelForPath), 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 avecinboxError: le type est enregistré et l’appelant affiche l’erreur. -
Mise en page :
autoFocussur le groupe de boutons radio ; largeurmin(560px, 100vw - 32px); plein écran surBreakpoints.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()etsavedCountincrémenté, pour que le panneau des liens relisenodeType. Les sections de l’ancien type deviennent retirables, celles du nouveau ne le sont pas. Le snackbar affiche « Classé comme <libellé> » ou « Type retiré ».inboxErrorest affiché danserrors.
- la ligne « Type : … » devient « Type : <libellé> » ou « Sans type », suivie d’un bouton
- 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
leftInboxest 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 denode.aspects) ; - le focus suit le même traitement que pour « Classé ».
- si
- 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 demandatoryAspects: « 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-fournisseuravecdocumentdéjà porté : aspects ajoutés[facture, courrier], dans l’ordre de la définition ;T → null: aucun aspect ajouté,typeChangedvrai ;- 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:tacheest ajouté avec ses valeurs par défaut (statut: a-faire,priorite: normale) ;- événements
type.changed(before.typenul,addedAspects: ["tache"]), puisaspect.addedavecreason: "type.mandatory"; updated_atest modifié ;GET /api/nodes?type=tachetrouve 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
montantetecheance: trois aspects ajoutés,courrier.canal = poste; facture.communicationest trouvé parq(aspect_text).
- sans données : 400 sur
tache→projet:tacheest conservé avec ses données,projetest ajouté.DELETE …/aspects/tacherépond ensuite 200 ;DELETE …/aspects/projetrépond 400.tache→ sans type : aspects conservés,type.changedavecafter.typenul ;tachedevient retirable.- Retour à l’ancien type : aucune donnée à fournir, aucun aspect ajouté, seulement un
type.changed. - Idempotence :
- un second
PUTidentique, données comprises, répond 200, sans événement, sans changerupdated_at, et sans écraser l’aspect ; - même type avec un aspect obligatoire supprimé en SQL (données anciennes) :
aspect.added, pas detype.changed.
- un second
- Erreurs :
- type inconnu : 400 sur
type; - données pour un aspect non imposé : 400 sur
aspects.<a>; montant: -1: 400 suraspects.facture;tache.projetvers un nœud inconnu ou vers soi : 400 suraspects.tache.projet. Un UUID en majuscules est stocké en minuscules ;- 404 pour un nœud inconnu ou en corbeille.
- type inconnu : 400 sur
- Inbox : un nœud de l’inbox y reste après le changement de type (
inbox_atinchangé). - Contrat :
OpenApiContractITvé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.
changeTypereçoit les seuls aspects manquants, nettoyés. Avec la case cochée,DELETE …/inboxsuit ; 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 » sileftInbox, élément mis à jour sinon.api-problem.spec.ts:labelForPath('type')vaut « Type ».
Découpage
-
Backend (
feat(nodes): changement de type d'un nœud existant) :TypeChangeet son test ;prepareAspectextrait decreate(les tests existants servent de filet) ;NodeRepository.updateType,NodeService.changeTypeet ses événements ;ChangeTypeRequest, endpoint et description OpenAPI ;NodeTypeITetOpenApiContractIT.
Testable seule par Swagger UI.
-
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-formatet backlog.
- client régénéré (skill
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}puisPUT …/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
putAspectexiste 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}: lePATCHexigetitleet écrasebody(ADR 0006, 0007, 0014). DELETE /api/nodes/{id}/typepour retirer le type : un second endpoint pour un cas quetype: nullexprime déjà, commeparentId: nullpour la racine.- Un seul événement
type.changedcontenant les données des aspects ajoutés, sansaspect.added: les règlesaspect-addeddu jalon 3 manqueraient ces ajouts, ou devraient lire deux formes d’événement. node.updatedavec{before: {type}, after: {type}}:node.updateddécrit le titre et le corps. Un événement dédié est plus simple à filtrer pour l’historique et pour un futur déclencheurtype-changed.- Sortie de l’inbox par le serveur au changement de type, automatique ou par une option
inboxdans 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-selectde 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-selectdans 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
typesn’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
typeet les contraintestypesdes liens et desx-targetportent 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, etaspect.addedavecreason. 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ègleaspect-addeddefacture.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_atchange : 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 (
removeAspectsdans la requête, cases à cocher dans le dialogue). - Proposer les
suggestedAspectsdu 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
typesn’est plus satisfaite (santé des données). - Libellés de
type.changeddans l’historique, et déclencheurtype-changeddes 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}/typepournull → T,T → UetT → null(typeabsent, 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}, puisaspect.addedavecreason: "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.