GUI : les six registres ont un formulaire genere, et la sauvegarde aussi

CHAMPS_ECRITS_A_LA_MAIN est vide. Serveurs et applications, les deux plus
gros, sont passes au generateur — chargement, rendu et sauvegarde.

L EPREUVE QUI COMPTE. Ouvrir chaque vue et enregistrer sans rien toucher
doit renvoyer exactement le plan qu on vient de lire : 14 serveurs, 25
applications, 2 domaines, 4 bases, IDENTIQUE partout. C est ce qui separe
un formulaire genere d un formulaire qui en a l air — un champ visible a
l ecran et perdu en silence a l enregistrement serait le pire des deux
mondes. test_rendu_gui.py le mesure a chaque make prouver.

TROIS DEFAUTS TROUVES EN CHEMIN.

Le formulaire annoncait des defauts INVENTES : 2048 Mo, 2 coeurs, 16G. Il
n existe aucun defaut fixe — deriver_ressources calcule depuis les roles
portes (1024 et 1 pour infra-pki-01, 5632 et 4 pour collab-01). Un repere
faux fait croire qu on connait la valeur. Le schema nomme le champ derive,
et l ecran montre la valeur reelle de cet hote. L option vide d un select
dit desormais ce qu elle produira : « (defaut : asgard) ».

Une SECONDE occurrence du defaut d hier dormait dans sourceDeValeurs :
elle lisait encore data.nomenclature. Elle n avait jamais leve parce que
la vue Serveurs, seule a emprunter cette source, avait un formulaire ecrit
a la main. Elle a leve a la seconde ou le generateur l a prise. Le banc ne
voit que les chemins vivants : verifier_gui.py fait donc aussi une
verification STATIQUE, qui voit ce qui dort.

La validation client s accrochait a data-v, pose a la main sur trois
champs. Le formulaire genere l aurait perdu et la validation serait passee
au vert sur ZERO champ. Le generateur marque chaque controle, et la
sauvegarde refuse si elle n en inspecte aucun.

DEUX CHAMPS GARDENT LEUR EDITEUR, et le schema le dit (x-editeur) : la
matrice des integrations montre les universelles et les exemptions, et
l editeur de liens contraint le role a meta/liens.yml. Le generateur s
efface plutot que de remplacer un editeur qui en sait plus que lui.

LIMITE : je n ai toujours pas ouvert ces pages dans un navigateur.

make prouver : CONFORME, 61 OK, 0 echec, 1 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-08 18:26:33 -04:00
parent c0ea0c8e71
commit 2887b57f0c
9 changed files with 383 additions and 90 deletions

View file

@ -1,5 +1,66 @@
# CHANGELOG — Set-OPS
## 2026-09-08 (4) — Les SIX registres ont un formulaire genere
**62 preuves. `make prouver` : CONFORME, 61 OK, 0 echec, 1 saute.**
`CHAMPS_ECRITS_A_LA_MAIN` est **vide** : plus un seul champ recopie a la main.
### L'epreuve qui compte
Ouvrir chaque vue et enregistrer **sans rien toucher** doit renvoyer au serveur
exactement le plan qu'on vient de lire. C'est ce qui separe un formulaire genere d'un
formulaire qui a l'air genere : un champ visible a l'ecran et perdu en silence a
l'enregistrement serait le pire des deux mondes.
/api/serveurs 14 entite(s) IDENTIQUE
/api/applications 25 entite(s) IDENTIQUE
/api/domaines 2 entite(s) IDENTIQUE
/api/bases 4 entite(s) IDENTIQUE
`test_rendu_gui.py` le mesure desormais a chaque `make prouver`.
### Trois defauts trouves en chemin
**Le formulaire annoncait des defauts inventes.** « 2048 » pour la memoire, « 2 » pour les
coeurs, « 16G » pour le disque. Il n'existe aucun defaut fixe : `deriver_ressources`
calcule la taille depuis les ROLES que l'hote porte — 1024 Mo et 1 coeur pour
`infra-pki-01`, 5632 et 4 pour `collab-01`. Un repere faux est pire qu'aucun : il fait
croire qu'on connait la valeur. Le schema nomme maintenant le champ derive
(`x-defaut-derive`), et le formulaire affiche la valeur REELLE de cet hote. De meme,
l'option vide d'un `<select>` dit le defaut qu'elle produira : « (défaut : asgard) ».
**Une SECONDE occurrence du defaut d'hier dormait.** `sourceDeValeurs` lisait encore
`data.nomenclature` — le meme `data` qui n'existe pas. Elle n'avait jamais leve parce que
la vue Serveurs, seule a emprunter cette source, avait encore un formulaire ecrit a la
main. Elle a leve **a la seconde** ou le generateur l'a prise, et c'est le banc de rendu
qui l'a dit.
Le banc ne voit que les chemins VIVANTS. `verifier_gui.py` fait donc desormais une
verification STATIQUE : une fonction qui lit `data.` sans le declarer ni le recevoir est
refusee. Elle voit aussi ce qui dort. Son premier essai a signale `dessinerReseau()` a
tort — `data` y est declare en second declarateur d'un `const` multiple ; corrige, parce
qu'un faux positif dans une garde finit toujours par la faire desactiver.
**La validation client s'accrochait a `data-v`**, pose a la main sur trois champs. Le
formulaire genere l'aurait perdu, et `querySelectorAll` aurait rendu une liste vide : la
validation serait passee au vert sur ZERO champ. Le generateur marque maintenant chaque
controle (`data-champ`), et la sauvegarde REFUSE si elle n'inspecte aucun champ — un
controle qui ne trouve rien ne dit pas « tout va bien ».
### Deux champs gardent leur editeur, et le schema le dit
`integrations` et `liens` ne sont PAS generes, volontairement. La matrice des integrations
montre aussi les universelles (non decochables) et les exemptions `sauf_role` : un champ
texte genere ferait lire un plan silencieux comme « cet hote n'est pas supervise »,
l'inverse exact de la politique. L'editeur de liens contraint le role a ce que le groupe
porteur accepte (`meta/liens.yml`). Le schema porte donc `x-editeur`, et le generateur
s'efface — au lieu de remplacer un editeur qui en sait plus que lui.
### La limite
Je n'ai toujours pas ouvert ces pages dans un navigateur. Le banc prouve qu'elles rendent
sans lever et qu'un aller-retour ne perd rien ; il ne dit rien de leur lisibilite.
## 2026-09-08 (3) — Quarante lignes de memoire que chaque « Sauvegarder » detruisait
**62 preuves (P01-P62). `make prouver` : CONFORME, 61 OK, 0 echec, 1 saute.**

View file

@ -19,7 +19,7 @@
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 40 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 99 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |

View file

@ -31,32 +31,40 @@
"description": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici.",
"items": {
"type": "string"
}
},
"x-editeur": "matrice"
},
"noeud": {
"type": "string",
"title": "Nœud Proxmox",
"description": "Surcharge le defaut de `make config`."
"description": "Surcharge le defaut de `make config`.",
"x-defaut-intrant": "proxmox_clone_noeud",
"x-source-valeurs": "intrants.proxmox_noeuds"
},
"stockage": {
"type": "string",
"title": "Stockage",
"description": "Surcharge le defaut."
"description": "Surcharge le defaut de `make config`.",
"x-defaut-intrant": "proxmox_clone_stockage",
"x-source-valeurs": "intrants.proxmox_stockages"
},
"disque": {
"type": "string",
"title": "Disque",
"description": "Ex. `32G`. Vide = derive des empreintes des roles."
"description": "Ex. `32G`. Vide = derive des empreintes des roles.",
"x-defaut-derive": "disque_taille"
},
"memoire": {
"type": "integer",
"title": "Mémoire (Mo)",
"description": "Vide = derive des empreintes des roles."
"description": "Vide = derive des empreintes des roles.",
"x-defaut-derive": "memoire"
},
"coeurs": {
"type": "integer",
"title": "Cœurs",
"description": "Vide = derive des empreintes des roles."
"description": "Vide = derive des empreintes des roles.",
"x-defaut-derive": "coeurs"
}
},
"additionalProperties": false,
@ -132,7 +140,8 @@
"role",
"vers"
]
}
},
"x-editeur": "liens"
},
"websocket": {
"type": "boolean",

View file

@ -119,6 +119,12 @@ c'est le feu vert pour appliquer.
Édition → **Sauvegarder** (écrit le registre) → **Appliquer le plan** (régénère
`hosts.yml`). L'écriture directe de l'inventaire est refusée (409).
**Les formulaires des six registres sont générés** depuis `docs/audit/schema-plan.json`
(`make schema`), dérivé des constantes du moteur. La sauvegarde en dérive aussi : un
champ ajouté au plan apparaît à l'écran *et* arrive au fichier. Deux exceptions
déclarées au schéma (`x-editeur`) : la matrice des intégrations et l'éditeur de liens
gardent leur éditeur propre, plus riche que ce que le schéma sait dire.
### Via le CLI / `make`
Chaque registre a son script miroir et ses cibles `make` :

View file

@ -558,25 +558,22 @@ def intrants_courants() -> dict:
# Cette table etait tenue A LA MAIN : quelqu'un devait penser a l'allonger quand un champ
# arrivait au plan, et P19 confrontait la liste au reel. Une copie gardee reste une copie.
#
# `bases_donnees` en est SORTIE la premiere : son formulaire est desormais GENERE depuis
# `docs/audit/schema-plan.json` (`make schema`), donc la liste s'en deduit au lieu d'etre
# recopiee. Les registres restants gardent leur declaration manuelle jusqu'a ce que leur
# formulaire passe au generateur — on ne bascule pas six vues d'un coup.
# LA TABLE EST VIDE, ET C'EST LE BUT. Les SIX registres ont leur formulaire GENERE depuis
# `docs/audit/schema-plan.json` (`make schema`) : la liste se DEDUIT du schema au lieu
# d'etre recopiee ici. `bases_donnees` est sortie la premiere, la nomenclature ensuite,
# puis `serveurs_bd`, `domaines_publics`, et enfin `serveurs` et `applications`.
#
# P19 lit `champs_ecrits_par_gui()` et non plus cette constante : le jour ou la table sera
# vide, elle gardera un mecanisme au lieu d'une liste.
CHAMPS_ECRITS_A_LA_MAIN = {
"serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire",
"coeurs", "integrations"},
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
# nomenclature : plus AUCUN champ a la main non plus — elle est passee au generateur
# le 2026-09-08, et c'etait le dernier registre que le GUI ne savait pas ecrire du
# tout. Ajouter une fonction n'exige plus d'editer le YAML.
"nomenclature": set(),
}
# On la garde plutot que de la supprimer : le mecanisme doit pouvoir accueillir un champ
# qu'un formulaire ecrirait sans que le schema le decrive — et le jour ou ca arrive, il
# faudra l'ECRIRE ICI, visiblement, plutot que de le laisser vivre en silence.
#
# P19 lit `champs_ecrits_par_gui()` et non cette constante : elle garde un mecanisme, pas
# une liste.
CHAMPS_ECRITS_A_LA_MAIN: dict[str, set[str]] = {}
# Les registres dont le formulaire est GENERE : leurs champs viennent du schema.
REGISTRES_GENERES = {"bases_donnees", "nomenclature", "serveurs_bd", "domaines_publics"}
REGISTRES_GENERES = {"bases_donnees", "nomenclature", "serveurs_bd",
"domaines_publics", "serveurs", "applications"}
def champs_ecrits_par_gui() -> dict:
@ -2440,10 +2437,10 @@ HTML = r"""<!doctype html>
function chargerApplications(data) {
const a = (data.applications || {}).applications || {};
applications = Object.entries(a).map(([id, x]) => ({
id, groupe: x.groupe || '', hote: x.hote || '', port: x.port || '',
requiert: (x.requiert || []).join(', '), expose: (x.expose || []).join(', '),
websocket: !!x.websocket,
// Les valeurs gardent LEUR TYPE : le formulaire est genere depuis le schema, qui
// sait qu'une liste se saisit par virgules. Les aplatir ici obligeait la sauvegarde
// a deviner comment les reconstruire.
applications = Object.entries(a).map(([id, x]) => Object.assign({id}, x, {
liens: (x.liens || []).map(l => ({vers: l.vers || '', role: l.role || ''}))}));
applicationsModifie = false;
}
@ -2465,7 +2462,10 @@ HTML = r"""<!doctype html>
if (nom === 'applications') return applications.map(x => x.id).filter(Boolean);
if (nom === 'groupes_operationnels') return groupes;
if (nom === 'serveurs_bd') return bdServeurs.map(x => x.nom).filter(Boolean);
if (nom === 'nomenclature.fonctions') return Object.keys((data.nomenclature || {}).fonctions || {});
// `nomenclature`, PAS `data.nomenclature` : il n'existe aucun `data` global. Cette
// ligne dormait — la vue Serveurs, seule a employer cette source, avait un
// formulaire ecrit a la main. Elle a leve a la seconde ou le generateur l'a prise.
if (nom === 'nomenclature.fonctions') return Object.keys((nomEdit.fonctions || nomenclature.fonctions) || {});
// La zone se choisit dans les zones DECLAREES — et on la lit dans la copie en
// cours d'edition, pour qu'une zone ajoutee a l'instant soit offerte tout de suite.
// `{v, t}` : la valeur ecrite au plan reste le numero, l'ecran montre le libelle.
@ -2473,6 +2473,11 @@ HTML = r"""<!doctype html>
// groupes d'un hote pour lui deriver ses SAN. Offrir des HOTES ici produirait un
// certificat correct sur un nom que personne ne peut appeler.
if (nom === 'groupes_edge') return groupes.filter(g => g.startsWith('serveur_nginx'));
// Les listes que le SITE a declarees dans les intrants (noeuds, stockages).
if (nom.startsWith('intrants.')) {
const v = ((intrants || {}).valeurs || {})[nom.slice(9)];
return Array.isArray(v) ? v : [];
}
if (nom === 'nomenclature.categories') {
const cats = (nomEdit.categories || {});
return Object.keys(cats).map(k => ({v: k, t: k + ' — ' + ((cats[k] || {}).libelle || '?')}));
@ -2481,6 +2486,11 @@ HTML = r"""<!doctype html>
}
function champDepuisSchema(cle, spec, valeur, entite, surChangement) {
// UN CHAMP PEUT AVOIR SON PROPRE EDITEUR (`x-editeur`), plus riche que ce que le
// schema sait dire : la matrice des integrations montre aussi les universelles et
// leurs exemptions ; l'editeur de liens contraint le role a ce que le groupe
// porteur accepte. Le generateur s'efface — la vue les rend elle-meme.
if (spec['x-editeur']) return '';
const titre = echapper(spec.title || cle);
const aide = spec.description ? ` title="${echapper(spec.description)}"` : '';
// Marqueur discret : le schema SAIT ce qui est requis, le formulaire ecrit a la
@ -2488,6 +2498,11 @@ HTML = r"""<!doctype html>
// collait au libelle et se lisait « Base requis ».
const req = (spec['x-requis'] ? '<abbr class="dsn" title="Champ requis" style="text-decoration:none"> *</abbr>' : '');
const appel = (expr) => `${surChangement}('${cle}', ${expr})`;
// `data-champ` remplace l'ancien `data-v`, pose a la main sur trois champs de la
// vue Serveurs. La validation client (`serveurValeurValide`) le lit ; sans lui,
// elle aurait cherche des elements qui n'existent plus et serait passee au vert
// sur ZERO champ. Un controle qui ne trouve rien ne dit pas « tout va bien ».
const marque = ` data-champ="${echapper(cle)}"`;
// Liste fermee : soit fixe (enum), soit alimentee a l'execution, soit dependante
// d'un autre champ de la meme entite.
@ -2501,13 +2516,18 @@ HTML = r"""<!doctype html>
}
if (options) {
// Une option est soit une valeur nue, soit {v: valeur ecrite, t: texte affiche}.
const opts = ['<option value="">—</option>'].concat(options.map(o => {
// L'option VIDE n'est pas « rien » : c'est « le defaut ». Quand le schema nomme
// l'intrant qui le porte (`x-defaut-intrant`), on l'affiche, plutot que de
// laisser l'operateur deviner ce qu'un champ vide produira.
const nomDef = spec['x-defaut-intrant'];
const valDef = nomDef ? ((intrants || {}).valeurs || {})[nomDef] : null;
const libVide = valDef ? `(défaut : ${valDef})` : '—';
const opts = [`<option value="">${echapper(libVide)}</option>`].concat(options.map(o => {
const v = (o && typeof o === 'object') ? o.v : o;
const txt = (o && typeof o === 'object') ? o.t : o;
return `<option value="${echapper(v)}"${String(v) === String(valeur == null ? '' : valeur) ? ' selected' : ''}>${echapper(txt)}</option>`;
})).join('');
return `<label class="champ"${aide}><span>${titre}${req}</span><select onchange="${appel('this.value')}">${opts}</select></label>`;
return `<label class="champ"${aide}><span>${titre}${req}</span><select${marque} onchange="${appel('this.value')}">${opts}</select></label>`;
}
if (spec.type === 'object' && spec.properties) {
// BLOC FIXE (`sous_champs` au schema) : ses cases une fois, sans « ajouter ».
@ -2522,10 +2542,20 @@ HTML = r"""<!doctype html>
+ `<div class="grille">${cases}</div></div>`;
}
if (spec.type === 'boolean') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`;
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${marque}${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`;
}
// Repere de saisie : la valeur REELLEMENT derivee pour cette entite, lue dans
// l'inventaire genere. Le formulaire ecrit a la main annoncait « 2048 » et « 2 » —
// des constantes inventees, alors que `deriver_ressources` calcule depuis les roles
// portes. Un repere faux fait croire qu'on connait la valeur.
const derive = spec['x-defaut-derive'];
let repere = '';
if (derive && entite && entite.nom) {
const h = hotes.find(x => x.nom === entite.nom);
if (h && h[derive] != null && h[derive] !== '') repere = ` placeholder="dérivé : ${echapper(h[derive])}"`;
}
if (spec.type === 'integer') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="number" value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="number"${marque}${repere} value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
if (spec.type === 'array' && (spec.items || {}).properties) {
return listeDepuisSchema(cle, spec, valeur, surChangement);
@ -2534,12 +2564,12 @@ HTML = r"""<!doctype html>
// Une liste de textes se saisit separee par des virgules : le YAML reste une
// liste, la saisie reste lisible.
const v = Array.isArray(valeur) ? valeur.join(', ') : '';
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(v)}" placeholder="a, b" oninput="${appel('this.value.split(\',\').map(x=>x.trim()).filter(Boolean)')}"></label>`;
return `<label class="champ"${aide}><span>${titre}${req}</span><input${marque} value="${echapper(v)}" placeholder="a, b" oninput="${appel('this.value.split(\',\').map(x=>x.trim()).filter(Boolean)')}"></label>`;
}
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
return `<label class="champ"${aide}><span>${titre}${req}</span><input${marque}${repere} value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) {
function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle, nomClef) {
const reg = schemaPlan[nomRegistre] || null;
if (!reg) return '<div class="vide">Aucun schéma pour ce registre — lancer <code>make schema</code>.</div>';
const props = (reg.entite || {}).properties || {};
@ -2548,7 +2578,7 @@ HTML = r"""<!doctype html>
if (reg['x-clef'] && surChangementCle) {
const k = reg['x-clef'];
morceaux.push(`<label class="champ" title="${echapper(k.description || '')}"><span>${echapper(k.title)}</span>` +
`<input value="${echapper(cleCourante == null ? '' : cleCourante)}" oninput="(${surChangementCle})(this.value)"></label>`);
`<input data-champ="${echapper(nomClef || 'cle')}" value="${echapper(cleCourante == null ? '' : cleCourante)}" oninput="(${surChangementCle})(this.value)"></label>`);
}
Object.keys(props).forEach(cle => {
const spec = Object.assign({}, props[cle], {'x-requis': requis.has(cle)});
@ -2748,7 +2778,7 @@ HTML = r"""<!doctype html>
function choisirApp(i) { selApp = i; dessiner(); }
function definirApplication(champ, v) {
const a = applications[selApp]; if (!a) return;
a[champ] = v; marquerApplicationsModifie();
poserChemin(a, champ, v); marquerApplicationsModifie();
if (champ === 'id') { const t = document.querySelector('#tuile-app-' + selApp + ' .carte-nom'); if (t) { t.textContent = v || '(sans id)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
else if (champ === 'groupe') {
const ok = Object.keys(liensAcceptes[a.groupe] || {});
@ -2841,8 +2871,6 @@ HTML = r"""<!doctype html>
return;
}
const a = applications[selApp];
const optGroupe = groupes.map(g => `<option value="${echapper(g)}" ${g === a.groupe ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const optHote = '<option value="">—</option>' + hotes.map(h => h.nom).filter(Boolean).map(n => `<option value="${echapper(n)}" ${n === a.hote ? 'selected' : ''}>${echapper(n)}</option>`).join('');
const dsns = basesDeApp(a, a.hote);
const liens = dsns.length
? `<div class="ch-bases">${dsns.map(b => `<div class="ch-base"><span class="badge-bd">${echapper(b.portee || 'groupe')}</span> <span class="mono">${echapper(dsnBase(b))}</span></div>`).join('')}</div>`
@ -2851,15 +2879,7 @@ HTML = r"""<!doctype html>
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.id || '(sans id)')}</span></div>
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer cette application" onclick="retirerApplication()">✕</button></div>
<div class="grille">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.id)}" placeholder="boutique" oninput="definirApplication('id', this.value)"></label>
<label class="champ"><span>Groupe (rôle)</span><select onchange="definirApplication('groupe', this.value)">${optGroupe}</select></label>
<label class="champ"><span>Hôte (VM)</span><select onchange="definirApplication('hote', this.value)">${optHote}</select></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(a.port)}" placeholder="3000" oninput="definirApplication('port', this.value)"></label>
<label class="champ"><span>WebSocket</span><span style="display:flex;align-items:center;gap:8px;height:2.1rem"><input type="checkbox" ${a.websocket ? 'checked' : ''} onchange="definirApplication('websocket', this.checked)"><span class="hint-dep">vhost compatible WebSocket (Collabora…)</span></span></label>
<label class="champ" style="grid-column:1/-1"><span>Requiert (applis)</span><input value="${echapper(a.requiert)}" placeholder="pg-principal, …" oninput="definirApplication('requiert', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Expose (FQDN)</span><input value="${echapper(a.expose)}" placeholder="forge.exemple.ca" oninput="definirApplication('expose', this.value)"></label>
</div>
${formulaireDepuisSchema('applications', a, 'definirApplication', a.id, "definirApplication.bind(null,'id')", 'id')}
<div class="section-tete" style="margin:12px 2px 4px">Liens (bindings)
<span class="compte">${(a.liens || []).length}</span></div>
${blocLiens(a)}
@ -2876,21 +2896,41 @@ HTML = r"""<!doctype html>
</article>`;
}
function listeDepuisTexte(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); }
function listeDepuisTexte(v) {
// Elle accepte desormais une VRAIE liste autant qu'un texte separe par des virgules.
// Les chargeurs aplatissaient `expose`, `requiert` et `integrations` en chaine ;
// le formulaire genere, lui, attend le type que le schema declare. Plutot que de
// toucher aux douze sites d'appel, la conversion vit ici, en un seul endroit.
if (Array.isArray(v)) return v.map(x => String(x).trim()).filter(Boolean);
return String(v || '').split(',').map(s => s.trim()).filter(Boolean);
}
async function sauvegarderApplications() {
const apps = {};
const propsA = ((schemaPlan.applications || {}).entite || {}).properties || {};
applications.forEach(a => {
if (!String(a.id).trim()) return;
const o = {groupe: a.groupe, hote: a.hote};
if (String(a.port).trim()) o.port = parseInt(a.port, 10) || a.port;
const req = listeDepuisTexte(a.requiert); if (req.length) o.requiert = req;
const exp = listeDepuisTexte(a.expose); if (exp.length) o.expose = exp;
if (a.websocket) o.websocket = true;
const lns = (a.liens || [])
.filter(l => String(l.vers).trim() && String(l.role).trim())
.map(l => ({vers: l.vers, role: l.role}));
if (lns.length) o.liens = lns;
const o = {};
Object.keys(propsA).forEach(cle => {
const spec = propsA[cle];
if (cle === 'liens') {
// Un lien incomplet n'est pas un lien : il ferait echouer le validateur du
// moteur a la sauvegarde suivante, sur une entree que personne n'a voulue.
const lns = (a.liens || []).filter(l => String(l.vers || '').trim() && String(l.role || '').trim())
.map(l => ({vers: l.vers, role: l.role}));
if (lns.length) o.liens = lns;
return;
}
if (spec.type === 'array') { const l = listeDepuisTexte(a[cle]); if (l.length) o[cle] = l; return; }
if (spec.type === 'boolean') { if (a[cle]) o[cle] = true; return; }
const v = a[cle];
if (v === undefined || v === null || String(v).trim() === '') return;
o[cle] = (spec.type === 'integer') ? (parseInt(v, 10) || v) : v;
});
// `groupe` et `hote` sont REQUIS : on les ecrit meme vides, pour que le
// validateur du moteur refuse clairement plutot que de recevoir un objet muet.
if (o.groupe === undefined) o.groupe = a.groupe || '';
if (o.hote === undefined) o.hote = a.hote || '';
apps[a.id] = o;
});
const rep = await fetch('/api/applications', {
@ -2904,7 +2944,7 @@ HTML = r"""<!doctype html>
}
function chargerServeurs(data) {
serveurs = (data.serveurs || []).map(s => ({...s, integrations: (s.integrations || []).join(', ')}));
serveurs = (data.serveurs || []).map(s => ({...s, integrations: s.integrations || []}));
serveursModifie = false;
}
@ -2913,7 +2953,7 @@ HTML = r"""<!doctype html>
function choisirServeur(i) { selServeur = i; dessiner(); }
function definirServeur(champ, v) {
const s = serveurs[selServeur]; if (!s) return;
s[champ] = v; marquerServeursModifie();
poserChemin(s, champ, v); marquerServeursModifie();
if (champ === 'nom') { const t = document.querySelector('#tuile-srv-' + selServeur + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
else if (champ === 'fonction' || champ === 'etat') dessiner();
}
@ -2921,7 +2961,7 @@ HTML = r"""<!doctype html>
const s = serveurs[selServeur]; if (!s) return;
const set = new Set(listeDepuisTexte(s.integrations));
if (actif) set.add(role); else set.delete(role);
s.integrations = Array.from(set).join(', '); marquerServeursModifie();
s.integrations = Array.from(set); marquerServeursModifie();
}
function ajouterServeur() {
serveurs.unshift({nom: '', fonction: (Object.keys(nomenclature.fonctions || {})[0] || ''), etat: 'planifie', noeud: '', stockage: '', disque: '', memoire: '', coeurs: '', integrations: '', statut: 'absent', vmid: '', adresse_ip: '', vlan: ''});
@ -3032,15 +3072,8 @@ HTML = r"""<!doctype html>
<div class="stats">${tuile(s.vmid, 'VMID')}${tuile(s.adresse_ip, 'IP')}${tuile(s.vlan, 'VLAN')}</div>
<div class="dsn" style="padding:5px 2px"><b>Plan</b> : ${echapper(s.etat)} &nbsp;·&nbsp; <b>Réel</b> : ${sondes[s.nom] === true ? '<span class="pt actif"></span> vivante' + (s.adresse_ip ? ' (' + echapper(s.adresse_ip) + ')' : '') : sondes[s.nom] === false ? '<span class="pt bloq"></span> injoignable' : '<span class="pt plan"></span> non sondé'}</div>
<div class="section-tete" style="margin:4px 2px 2px">Identité — saisie du plan (surcharge les défauts)</div>
${formulaireDepuisSchema('serveurs', s, 'definirServeur', s.nom, "definirServeur.bind(null,'nom')", 'nom')}
<div class="grille">
<label class="champ"><span>Serveur (fonction-NN)</span><input data-v="nom" value="${echapper(s.nom)}" placeholder="web-frontal-03" oninput="definirServeur('nom', this.value)"></label>
<label class="champ"><span>Fonction</span><select onchange="definirServeur('fonction', this.value)">${optFonction}</select></label>
<label class="champ"><span>État</span><select onchange="definirServeur('etat', this.value)">${optEtat}</select></label>
<label class="champ"><span>Nœud</span><select onchange="definirServeur('noeud', this.value)">${optDeListe(vi.proxmox_noeuds, s.noeud, libDef(vi.proxmox_clone_noeud))}</select></label>
<label class="champ"><span>Stockage</span><select onchange="definirServeur('stockage', this.value)">${optDeListe(vi.proxmox_stockages, s.stockage, libDef(vi.proxmox_clone_stockage))}</select></label>
<label class="champ"><span>Disque</span><input data-v="disque" value="${echapper(s.disque)}" placeholder="16G" oninput="definirServeur('disque', this.value)"></label>
<label class="champ"><span>Mémoire</span><input data-v="memoire" type="number" value="${echapper(s.memoire)}" placeholder="2048" oninput="definirServeur('memoire', this.value)"></label>
<label class="champ"><span>Cœurs</span><input data-v="coeurs" type="number" value="${echapper(s.coeurs)}" placeholder="2" oninput="definirServeur('coeurs', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Intégrations (rôles client)</span><div class="integ-cases">${univ}${exempts}${cases}</div><span class="dsn" style="font-size:11px">Les ✓ sont la politique des rôles : elles s'appliquent à tout hôte et ne se décochent pas.</span></label>
</div>
<div class="section-tete" style="margin:12px 2px 2px">Applications ici</div>${appsHtml}
@ -3061,25 +3094,40 @@ HTML = r"""<!doctype html>
return true;
}
function validerChampServeur(el) {
const ok = serveurValeurValide(el.getAttribute('data-v'), el.value);
const ok = serveurValeurValide(el.getAttribute('data-champ'), el.value);
el.classList.toggle('invalide', !ok);
return ok;
}
async function sauvegarderServeurs() {
const invalides = Array.from(document.querySelectorAll('#detail [data-v]')).filter(el => !validerChampServeur(el));
const champs = Array.from(document.querySelectorAll('#detail [data-champ]'));
const invalides = champs.filter(el => !validerChampServeur(el));
// Un controle qui n'inspecte AUCUN champ n'est pas un controle : si le formulaire
// cesse un jour de marquer ses contröles, on le dit plutot que de laisser passer.
if (!champs.length) { message('Formulaire non marqué : sauvegarde refusée.', 'erreur'); return; }
if (invalides.length) {
message('Corrige les ' + invalides.length + ' champ(s) en rouge avant de sauvegarder.', 'erreur');
invalides[0].focus();
return;
}
const srv = {};
const propsS = ((schemaPlan.serveurs || {}).entite || {}).properties || {};
serveurs.forEach(s => {
if (!String(s.nom).trim()) return;
const o = {fonction: s.fonction, etat: s.etat || 'planifie'};
['noeud', 'stockage', 'disque'].forEach(k => { if (String(s[k] || '').trim()) o[k] = s[k]; });
['memoire', 'coeurs'].forEach(k => { if (String(s[k] || '').trim()) o[k] = parseInt(s[k], 10) || s[k]; });
const integ = listeDepuisTexte(s.integrations); if (integ.length) o.integrations = integ;
const o = {};
Object.keys(propsS).forEach(cle => {
const spec = propsS[cle];
if (spec.type === 'array') {
const l = listeDepuisTexte(s[cle]); if (l.length) o[cle] = l; return;
}
const v = s[cle];
if (v === undefined || v === null || String(v).trim() === '') return;
o[cle] = (spec.type === 'integer') ? (parseInt(v, 10) || v) : v;
});
// `etat` a un defaut METIER, pas un defaut de schema : un serveur sans etat est
// planifie, jamais actif. Le declarer ici plutot qu'au schema evite de le poser
// par ecrit dans chaque plan.
if (!o.etat) o.etat = 'planifie';
srv[s.nom] = o;
});
const rep = await fetch('/api/serveurs', {

View file

@ -69,17 +69,36 @@ REGISTRES: dict = {
"etat": {"type": "string", "enum": sorted(ETATS_SERVEUR),
"libelle": "État",
"aide": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible."},
# UN EDITEUR PROPRE, ET C'EST VOULU. La matrice de cases a cocher montre
# aussi les integrations UNIVERSELLES (non decochables) et les exemptions
# `sauf_role`. Un champ texte genere serait une regression : un plan
# silencieux se lirait « cet hote n'est pas supervise », l'inverse exact de
# la politique. Le schema le DIT, au lieu de laisser le generateur l'ecraser.
"integrations": {"type": "array", "items": {"type": "string"},
"libelle": "Intégrations facultatives",
"libelle": "Intégrations facultatives", "editeur": "matrice",
"aide": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici."},
"noeud": {"type": "string", "libelle": "Nœud Proxmox",
"source_valeurs": "intrants.proxmox_noeuds",
"defaut_intrant": "proxmox_clone_noeud",
"aide": "Surcharge le defaut de `make config`."},
"stockage": {"type": "string", "libelle": "Stockage", "aide": "Surcharge le defaut."},
"stockage": {"type": "string", "libelle": "Stockage",
"source_valeurs": "intrants.proxmox_stockages",
"defaut_intrant": "proxmox_clone_stockage",
"aide": "Surcharge le defaut de `make config`."},
# `defaut_derive` NOMME LE CHAMP DE L'INVENTAIRE qui porte la valeur reellement
# derivee pour CET hote. Le formulaire ecrit a la main annoncait « 2048 » et
# « 2 » en repere de saisie : des constantes inventees. Il n'existe aucun
# defaut fixe — `deriver_ressources` calcule la taille depuis les ROLES que
# l'hote porte (5632 Mo et 4 coeurs pour `collab-01`). Un repere faux est pire
# qu'aucun : il fait croire qu'on connait la valeur.
"disque": {"type": "string", "libelle": "Disque",
"defaut_derive": "disque_taille",
"aide": "Ex. `32G`. Vide = derive des empreintes des roles."},
"memoire": {"type": "integer", "libelle": "Mémoire (Mo)",
"defaut_derive": "memoire",
"aide": "Vide = derive des empreintes des roles."},
"coeurs": {"type": "integer", "libelle": "Cœurs",
"defaut_derive": "coeurs",
"aide": "Vide = derive des empreintes des roles."},
},
},
@ -105,7 +124,11 @@ REGISTRES: dict = {
# `items: {type: object}` ne disait rien de la FORME : un formulaire genere
# aurait offert « ajouter un lien » sans savoir quelles cases y mettre.
# `valider_applications` exige `vers` et `role` — on les nomme.
"liens": {"type": "array", "libelle": "Liens (bindings)",
# EDITEUR PROPRE lui aussi : il contraint le ROLE du lien a ceux que le
# groupe porteur accepte (`roles/<groupe>/meta/liens.yml`) et montre les
# variables injectees. Le generateur, lui, offrirait un champ libre — il
# saurait moins que l'editeur qu'il remplacerait.
"liens": {"type": "array", "libelle": "Liens (bindings)", "editeur": "liens",
"aide": "Roles acceptes par le role porteur (meta/liens.yml).",
"entrees": {
"vers": {"type": "string", "requis": True, "libelle": "Vers",
@ -315,6 +338,11 @@ def construire() -> dict:
# Meme intention que `x-source-valeurs`, mais la liste change selon la
# valeur d'un autre champ de la meme entite.
p["x-source-selon"] = d["source_selon"]
for cle_src, cle_dst in (("editeur", "x-editeur"),
("defaut_intrant", "x-defaut-intrant"),
("defaut_derive", "x-defaut-derive")):
if cle_src in d:
p[cle_dst] = d[cle_src]
if "source_valeurs" in d:
# Extension hors JSON Schema : dit au formulaire d'offrir une LISTE
# fermee, alimentee a l'execution. C'est ce qui rend l'hote fantome

View file

@ -51,7 +51,23 @@ Elem.prototype.setAttribute = function () {};
Elem.prototype.getAttribute = function () { return ''; };
Elem.prototype.addEventListener = function () {};
Elem.prototype.querySelector = function () { return null; };
Elem.prototype.querySelectorAll = function () { return []; };
// `querySelectorAll('[data-champ]')` rend vraiment les controles du dernier rendu : le
// GUI s'en sert pour valider avant d'enregistrer, et un stub qui rend toujours [] ferait
// passer cette validation sur ZERO champ — le banc mesurerait alors moins que rien.
Elem.prototype.querySelectorAll = function (sel) {
if (!/data-champ/.test(String(sel))) { return []; }
var html = this.innerHTML || '';
var out = [], re = /<(input|select)([^>]*?)data-champ="([^"]*)"([^>]*)>/g, m;
while ((m = re.exec(html)) !== null) {
var attrs = m[2] + m[4];
var v = /\svalue="([^"]*)"/.exec(attrs);
out.push({__champ: m[3], value: v ? v[1] : '',
getAttribute: function (n) { return n === 'data-champ' ? this.__champ : null; },
classList: {add: function () {}, remove: function () {}, toggle: function () {}},
focus: function () {}});
}
return out;
};
Elem.prototype.focus = function () {};
Elem.prototype.remove = function () {};
Elem.prototype.closest = function () { return null; };
@ -62,7 +78,14 @@ var document = {
getElementById: function (id) { return __elems[id] || (__elems[id] = new Elem(id)); },
createElement: function () { return new Elem('cree'); },
querySelector: function () { return null; },
querySelectorAll: function () { return []; },
// `#detail [data-champ]` : le GUI interroge le DOCUMENT, pas l'element. Un stub qui
// rendait [] ici faisait croire que le formulaire n'etait pas marque — et la
// sauvegarde des serveurs refusait, silencieusement, dans le banc seul.
querySelectorAll: function (sel) {
var m = /^#([\w-]+)\s+(.+)$/.exec(String(sel));
if (m && __elems[m[1]]) { return __elems[m[1]].querySelectorAll(m[2]); }
return [];
},
addEventListener: function () {},
body: new Elem('body'),
documentElement: new Elem('html'),
@ -77,10 +100,15 @@ function EventSource() { this.addEventListener = function () {}; this.close = fu
function alert() {} function confirm() { return false; } function prompt() { return null; }
var localStorage = {getItem: function () { return null; }, setItem: function () {}, removeItem: function () {}};
var __PAYLOAD__ = JSON.parse(process.env.PAYLOAD);
var __postsCaptures = {};
// `fetch` rend la VRAIE reponse de /api/inventaire ; les autres routes rendent un objet
// vide plutot que de jeter, pour que les sondes de fond ne masquent pas l'erreur cherchee.
function fetch(url) {
var corps = String(url).indexOf('/api/inventaire') === 0 ? __PAYLOAD__ : {};
function fetch(url, opts) {
if (opts && opts.method === 'POST') { __postsCaptures[String(url)] = JSON.parse(opts.body); }
// Un POST rend la MEME charge que l'inventaire : le GUI recharge apres sauvegarde, et
// lui rendre {} le ferait repartir d'un plan vide — un faux vert, pas un test.
var corps = (String(url).indexOf('/api/inventaire') === 0 || (opts && opts.method === 'POST'))
? __PAYLOAD__ : {};
return Promise.resolve({ok: true, status: 200, json: function () { return Promise.resolve(corps); },
text: function () { return Promise.resolve(''); }});
}
@ -166,6 +194,47 @@ class TestRenduGui(unittest.TestCase):
self.assertTrue(any("donneesQuiNexistentPas" in e for e in erreurs),
f"le banc n'a pas vu la reference absente : {erreurs}")
def test_charger_puis_sauvegarder_ne_change_pas_le_plan(self):
"""L'EPREUVE DECISIVE : ouvrir une vue et enregistrer sans rien toucher.
Les six formulaires sont GENERES, et leur sauvegarde l'est aussi : les champs
ecrits sont ceux que le schema declare. C'est ce qui evite le pire des deux
mondes — un champ visible a l'ecran et perdu en silence a l'enregistrement.
Encore faut-il le mesurer. On charge la vraie reponse de l'API, on ouvre chaque
vue, on appelle sa sauvegarde, on intercepte le POST, et on compare la charge au
plan REEL lu par le moteur. Aucune entite perdue, aucune en trop, aucun champ
different : sinon un simple aller-retour appauvrirait le plan.
"""
js = self.js.replace(APRES, r"""
charger()
.then(function () { setVue('serveurs'); choisirServeur(0); return sauvegarderServeurs(); })
.then(function () { setVue('applications'); if (applications.length) ouvrirApp(applications[0].id); return sauvegarderApplications(); })
.then(function () { setVue('domaines'); if (domaines.length) choisirDomaine(0); return sauvegarderDomaines(); })
.then(function () { setVue('bases'); return sauvegarderBases(); })
.then(function () { console.log('__RESULTAT__' + JSON.stringify(__postsCaptures)); })
.catch(function (e) { console.log('__RESULTAT__' + JSON.stringify({erreur: String(e && e.stack || e)})); });
""")
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
posts = self._jouer(js, payload)
self.assertNotIn("erreur", posts, posts.get("erreur", ""))
attendu = {
"/api/serveurs": (g.charger_serveurs(g.FICHIER_SERVEURS), "serveurs"),
"/api/applications": (g.charger_applications(g.FICHIER_APPLICATIONS), "applications"),
"/api/domaines": (g.charger_domaines(g.FICHIER_DOMAINES), "domaines_publics"),
"/api/bases": (g.charger_bases_donnees(g.FICHIER_BASES), "bases_donnees"),
}
for url, (plan, racine) in attendu.items():
with self.subTest(route=url):
envoye = (posts.get(url) or {}).get(racine)
self.assertIsNotNone(envoye, f"{url} n'a pas ete appele — la sauvegarde a-t-elle refuse ?")
reel = plan.get(racine) or {}
self.assertEqual(sorted(envoye), sorted(reel), "entites perdues ou ajoutees")
for cle in sorted(reel):
self.assertEqual(envoye[cle], reel[cle],
f"{racine}.{cle} : l'aller-retour a change l'entite")
def test_le_schema_est_bien_servi(self):
"""Le formulaire ne peut rien generer si l'API n'envoie pas le schema."""
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)

View file

@ -20,6 +20,60 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import inventory_gui as g # noqa: E402
# Les noms qui RESSEMBLENT a un global sans en etre un. `data` est le cas fondateur :
# chaque `charger*`/`sauvegarder*` en declare un LOCAL, si bien que le nom se lit partout
# comme s'il etait disponible — et ne l'est pas.
NON_GLOBALES = ("data",)
# Une fonction de premier niveau du <script> : ` function nom(args) {` ou sa variante
# `async`. Le GUI les ecrit toutes a quatre espaces d'indentation.
_FONCTION = re.compile(r"^ {4}(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)", re.M)
def variables_orphelines(js: str) -> list[str]:
"""Fonctions qui LISENT un nom surveille sans le declarer ni le recevoir.
POURQUOI CETTE GARDE (2026-09-08). `node --check` ne voit que la syntaxe. Le
formulaire genere des bases lisait `data.schema` — or `data` n'est pas un global de
cette page : c'est une const locale de `charger()`. Syntaxe parfaite,
`ReferenceError` a l'ouverture, et personne pour le dire.
`test_rendu_gui.py` l'attrape maintenant en EXECUTANT les vues — mais seulement les
chemins vivants. La preuve en est qu'une SECONDE occurrence dormait dans
`sourceDeValeurs` : la vue Serveurs, seule a emprunter cette source, avait encore un
formulaire ecrit a la main. Elle a leve a la seconde ou le generateur l'a prise.
Cette verification-ci est statique : elle voit aussi ce qui dort.
Sa limite, dite franchement : elle raisonne par fonction de premier niveau et par
motif, pas par portee lexicale. Une fonction imbriquee qui hériterait du `data` de
sa parente serait signalee a tort — on la nommerait alors autrement, ce qui est de
toute facon plus lisible.
"""
orphelins = []
bornes = [(m.start(), m.group(1), m.group(2)) for m in _FONCTION.finditer(js)]
for i, (debut, nom, args) in enumerate(bornes):
fin = bornes[i + 1][0] if i + 1 < len(bornes) else len(js)
corps = js[debut:fin]
parametres = {a.strip().split("=")[0].strip() for a in args.split(",") if a.strip()}
for surveille in NON_GLOBALES:
if surveille in parametres:
continue
# La declaration peut etre un SECOND declarateur : `const di = …, data = …`.
# Sans ce cas, `dessinerReseau()` etait signalee a tort — et un faux positif
# dans une garde finit toujours par la faire desactiver.
if re.search(rf"\b(?:const|let|var)\b[^;\n]*?(?<![\w.$]){surveille}\s*=", corps):
continue
for ligne in corps.split("\n"):
nue = ligne.strip()
if nue.startswith("//") or nue.startswith("*"):
continue
if re.search(rf"(?<![\w.$]){surveille}\s*[.\[]", nue):
orphelins.append(f"{nom}() lit `{surveille}` : {nue[:88]}")
break
return orphelins
def main() -> int:
if not shutil.which("node"):
print("node absent : verification de la syntaxe JS du GUI sautee.")
@ -36,7 +90,16 @@ def main() -> int:
if resultat.returncode != 0:
print(resultat.stderr, file=sys.stderr)
return 1
print("JS du GUI : syntaxe valide (node --check).")
orphelins = variables_orphelines("\n".join(blocs))
if orphelins:
print("erreur: des fonctions lisent une variable qu'elles n'ont pas :", file=sys.stderr)
for ligne in orphelins:
print(f" - {ligne}", file=sys.stderr)
print(" (declarer un global, ou passer la valeur en parametre)", file=sys.stderr)
return 1
print(f"JS du GUI : syntaxe valide (node --check), {len(NON_GLOBALES)} nom(s) surveille(s) "
f"sans reference orpheline.")
return 0

View file

@ -40,11 +40,20 @@ Le flux d'exploitation, de bout en bout :
propose que des **hôtes réels** : impossible de pointer vers un hôte fantôme. La console couvre le
schéma du plan — et la preuve **P19** le vérifie.
**Les formulaires sont GÉNÉRÉS.** Depuis le 2026-09-08, quatre registres sur six — **bases de
données**, **serveurs de BD**, **domaines** et **nomenclature** — ne portent plus de formulaire
écrit à la main : ils le construisent depuis `docs/audit/schema-plan.json`, lui-même dérivé des
constantes du moteur (`make schema`). Un champ ajouté au plan apparaît donc à l'écran **sans qu'on
touche à l'interface**. Deux preuves le tiennent : **P61** refuse qu'un champ des plans réels
**Les formulaires sont GÉNÉRÉS.** Depuis le 2026-09-08, **les six registres** — serveurs,
applications, bases de données, serveurs de BD, domaines et nomenclature — ne portent plus de
formulaire écrit à la main : ils le construisent depuis `docs/audit/schema-plan.json`, lui-même
dérivé des constantes du moteur (`make schema`). Un champ ajouté au plan apparaît donc à l'écran
**sans qu'on touche à l'interface** — et il arrive au fichier, parce que la sauvegarde dérive du
même schéma. Ouvrir une vue et enregistrer sans rien toucher renvoie **exactement** le plan qu'on
vient de lire ; c'est mesuré à chaque `make prouver`.
Deux champs gardent volontairement leur propre éditeur, et le schéma le déclare : la **matrice des
intégrations** (elle montre aussi les universelles, non décochables, et les exemptions) et
l'**éditeur de liens** (il contraint le rôle à ce que `meta/liens.yml` accepte). Le générateur
s'efface plutôt que de remplacer un éditeur qui en sait plus que lui.
Deux preuves tiennent le schéma : **P61** refuse qu'un champ des plans réels
manque au schéma, ou que le schéma décrive une *forme* que les plans n'ont pas ; **P62** refuse
qu'un champ accepté par les *validateurs du moteur* manque au schéma — sans quoi le formulaire ne
saurait pas offrir une fonctionnalité que Set-OPS possède déjà.