# fwtozza — l'essentiel

Tout ce qu'il faut pour **écrire ou retoucher un composant** fwtozza, et rien d'autre.
Ce qui n'est pas énoncé ici comme une règle n'est pas une règle : le reste est libre.
(Référence complète : `fwtozza.md`. Doctrine de découpage : `ia_spe.md`.)

## 0. Le modèle

Des objets échangent des **messages** sans se connaître, à travers un **broker** qui
aiguille et **valide chaque émission contre son contrat**.

- **`broker.js`** — l'aiguilleur : `register` (déclaration de ce qu'on émet / reçoit),
  `publish` (émission), `handleMessage` (réception). **Un broker par environnement**
  (un dans le navigateur, un sur le serveur).
- **`catalog.js`** — les contrats, en **pure donnée** : source de vérité unique, injectée
  au broker à la construction.
- **`engine.js`** — l'interpréteur du catalogue, générique, jamais régénéré : `validate`,
  `validateCatalog`, `upcastToLatestRevision`, `upcast`, `canonicalStringify`.

**Modules ES natifs, aucun build** : le fichier qui s'exécute est le fichier qui a été
écrit. Bibliothèques ESM autorisées ; JSX/TypeScript non. Dépendances de dev libres.

fwtozza ne régit que les **frontières entre composants** ; à l'intérieur d'un composant on
programme normalement. On découpe par **fonction et/ou déploiement**, jamais par géométrie
d'écran : deux morceaux indissociables (même état, déployés ensemble) = **un** composant.

---

## 1. Le message et son contrat

Enveloppe à clé unique : `{ nom_du_message: { /* payload */ } }`

- Le broker **tolère plusieurs clés** dans une enveloppe : chacune est validée et livrée
  indépendamment, dans l'ordre de déclaration, sans atomicité. Qui veut un tout-ou-rien
  fait **un seul** message à payload composite.
- Un payload est de la **donnée JSON pure** — les cinq types et rien d'autre. Jamais de
  `Date`, d'instance de classe, de fonction, de handle. Une date : timestamp `number` ou
  string ISO.
- **Tout champ est déclaré au catalogue** : aucun champ non déclaré, aucune clé implicite.
- Deux clés d'enveloppe sont **réservées au framework** : `id` et `session` — au niveau de
  l'enveloppe, jamais dans un payload ni au catalogue (§5).

```javascript
text_entered: {
	versions: {
		1: {
			revisions: {
				0: { fields: {
					text: { type: "string", required: true },
				} },
			},
		},
	},
	examples:        { 1: { 0: [ { text: "bonjour" } ] } },
	counterexamples: { 1: { 0: [ { reason: "text manquant", payload: {} } ] } },
}
```

- **Vocabulaire des champs** : `type` (`"string"`, `"number"`, `"boolean"`, `"object"`,
  `"array"`), `required`, `pattern`, `enum`, `default`, `fields` (sous-objet), `items`
  (éléments de liste) ; plus **`oneOf`** au niveau d'une révision (exactement un champ
  présent parmi N : `oneOf: ["text", "image"]`). **Aucune règle conditionnelle par valeur.**
- **Invariant `oneOf`** : ses membres restent **optionnels et sans `default`** — sinon
  l'upcast en comblerait un et casserait « exactement un ».
- **Un nom de champ ne contient jamais de `.`** (réservé comme séparateur de chemin
  d'`upcastFrom`).
- **Révision** = évolution compatible (ajout de champs), gravée en **snapshot complet**,
  jamais un diff. La courante est la plus haute ; le moteur la dérive.
- **Émetteur** : déclare la révision qu'il produit et est validé contre elle.
  **Abonné** : ne déclare **jamais** de révision, reçoit toujours la forme canonique.
- `examples` / `counterexamples` (`{ reason, payload }`) **prouvent** le contrat —
  `node test_engine.js` les rejoue. Écris-les.

**Le broker est strict :**

- inscription validée **en bloc et atomique** — une seule déclaration fautive (message,
  version ou révision inconnus, collision d'`instanceName`, objet sans `instanceName`)
  rejette l'objet **entier**, `register` renvoie `false`, l'application continue sans lui
  (jamais d'exception) ;
- **payload invalide jeté à l'émission**, violations listées en console ;
- **catalogue linté** à la construction du broker (`validateCatalog`) : types, `fields`/
  `items` manquants, `pattern`/`enum`/`default` incohérents, `oneOf` fautif, `.` dans un
  nom de champ, `upcastFrom` malformé, et la **garantie de forme canonique** — un champ
  que la révision courante exige mais qu'une révision antérieure pouvait omettre **doit**
  porter un `default` ;
- **un contrat validé ne se modifie jamais** : on révise ou on versionne (§7).

---

## 2. La livraison

- **FIFO à épuisement** (run-to-completion). La validation est **immédiate**, sur la pile
  de l'émetteur ; le `publish` le plus externe **vide la file avant de rendre la main** —
  vu de l'extérieur tout est synchrone (un test peut asserter juste après `publish`).
- Un `publish` émis depuis un `handleMessage` est mis en file et livré **après** le
  message courant, jamais imbriqué.
- Chaque `handleMessage` est appelé **sous try/catch** : une exception est signalée en
  console mais ne prive ni les abonnés ni les messages suivants.
- **Garde-fou anti-cycle** : au-delà de `Broker.DELIVERY_BUDGET` (1000) livraisons en un
  épuisement, la file est jetée avec une erreur — un cycle A→B→A est un diagnostic, pas un
  gel.
- Chaque abonné reçoit une **copie privée** du payload (`structuredClone`).
- Une émission valide **sans abonné** est livrée à personne, sans erreur.

---

## 3. L'objet standard

Signature **unique**, client et serveur : `(broker, instanceName, deps)`.

```javascript
class StandardObject {

	instanceName;      // string qui différencie une instance d'une autre
	broker;            // le broker auprès duquel l'objet s'inscrit et par lequel il émet
	deps;              // tout le reste : supports DOM, pools, sockets, réglages
	supports;          // les nœuds HTML qui portent le rendu, normalisés en carte
	support;           // raccourci vers supports.main — le cas courant, à un seul support
	verbose = false;

	// Un émetteur peut préciser la révision qu'il produit ({ name, version, revision }) ;
	// omise, c'est la révision courante. Un abonné ne déclare JAMAIS de révision.
	emits    = [{ name: "some_message", version: 1 }];
	receives = [{ name: "other_message", version: 1 }];

	constructor(broker, instanceName, deps = {}) {
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.supports = deps.support?.nodeType ? { main: deps.support } : (deps.support ?? {});
		this.support  = this.supports.main;
		this.render(); // objets client seulement
		if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): register");
		this.broker.register(this, this.emits, this.receives);
	}

	handleMessage(message) {
		if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): reçoit " + JSON.stringify(message));
		// Selon les clés présentes : rafraîchir l'affichage et/ou émettre via
		// this.broker.publish(this, { some_message: { ... } });
		// Le payload reçu est déjà validé, upcasté en forme canonique, et privé.
	}

	// Symétrique du constructeur. On nettoie ce qu'on a peint ; jamais ce qu'on a reçu.
	destroy() {
		this.broker.unregister(this, this.emits, this.receives);
		for (const node of Object.values(this.supports)) node.replaceChildren();
	}

}
export default StandardObject;
```

Un objet **serveur** est identique, avec un `deps` sans `support` et sans `render()`.

### Les cinq règles de `deps`

1. **Tout ce qui n'est ni le broker ni le nom d'instance arrive par `deps`, à la
   construction.** Jamais de `process.env`, jamais d'import de configuration, jamais de
   singleton : ce qu'un composant ne reçoit pas, il ne l'a pas.
2. **`deps` est opaque au framework, à une clé près : `support`** (normalisée au
   constructeur, nettoyée par `destroy()`). `deps` n'est pas un second catalogue.
3. **Un handle ne voyage jamais en message** (la règle du JSON pur l'interdit déjà) : une
   ressource se passe au constructeur.
4. **La propriété reste au point de composition** : `destroy()` ne ferme pas ce qu'il n'a
   pas ouvert. Seule exception : un **canal** ferme son transport sur violation de
   protocole (handshake refusé, timeout), jamais au titre du cycle de vie.
5. **On passe la ressource ouverte, pas son URL** : un pool, pas une chaîne de connexion ;
   un socket, pas une adresse.

`config.js` / `.env` peuvent exister ; seuls **`server.js` et `index.html`** ont le droit
de les importer.

### Supports et cycle de vie

- Un composant dont l'interface vit légitimement à deux endroits reçoit une **carte** :
  `{ support: { liste: node, saisie: node } }` → il peint dans `this.supports.liste` /
  `this.supports.saisie`, et `this.support` vaut `undefined` — c'est correct.
- **Un objet non désinscrit reste abonné** : toute destruction passe par `destroy()`.
  `a = null` ne détruit rien (abonné fantôme + fuite mémoire).
- La désinscription est **immédiate** : dès `unregister`, plus aucune livraison n'atteint
  l'objet, y compris les messages déjà en file.
- L'**identité de l'objet** est contrôlée à l'émission comme à la désinscription : un
  homonyme ne peut ni émettre ni désinscrire à la place d'un autre.
- **Composants uniques** : le cas normal est une instance par classe. Ne fais **jamais**
  transiter un nom d'instance dans un payload. Pour corréler un aller-retour : un `ref`
  **de message** (UUID). Pour atteindre un client : la clé `id` (§5).

---

## 4. L'audit du graphe

`broker.audit(transits)` — appelé **en fin de composition**, une fois tout monté ; il ne
fait que lire et **rend** la liste des anomalies. `transits` = la liste des `CHANNEL` des
canaux montés (sans elle, tout message qui traverse un canal ressemble à un orphelin).

| code | anomalie |
|---|---|
| `orphan_emit` | émis localement, aucun abonné local, ne traverse aucun canal |
| `dead_receive` | reçu localement, aucun émetteur local, ne traverse aucun canal |
| `unused_contract` | au catalogue, ni émis ni reçu par personne |
| `version_gap` | émis en version X, attendu en version Y ≠ X |

```javascript
for (const a of broker.audit([CHANNEL]))
	console.warn("AUDIT (" + a.code + "): " + a.message);
```

L'audit ne voit ni l'autre côté du canal, ni les payloads, ni les composants non montés.

---

## 5. Client / serveur

```
aller  : ChatBox ──publish──▶ broker client ──▶ canal client ┄(réseau)┄ canal serveur
         ──publish──▶ broker serveur ──▶ TextBoard
retour : TextBoard ──publish──▶ broker serveur ──▶ canal serveur ┄(réseau)┄ canal client
         ──publish──▶ broker client ──▶ ChatBox, MessageNotifier
```

**Rien ne parle jamais directement au canal** : chaque objet publie auprès de **son**
broker ; le canal n'est qu'un abonné parmi d'autres. Émettre = **publier au broker**,
jamais « envoyer au serveur » — un frère local peut réagir à la même émission.

### Un catalogue par canal

Chaque `x.catalog.js` exporte le trio indissociable de sa population de clients :

```javascript
export const FINGERPRINT = "manual-text-2"; // millésime manuel, étiquette consultative

export const SHARED = {
	text_entered: { /* client → serveur */
		versions: { 1: { revisions: { 0: { fields: {
			text: { type: "string", required: true },
			ref:  { type: "string", required: true }, // corrèle UN message, pas un composant
		} } } } },
		examples:        { 1: { 0: [ { text: "bonjour", ref: "b1f0c3a2-…" } ] } },
		counterexamples: { 1: { 0: [ { reason: "ref manquant", payload: { text: "bonjour" } } ] } },
	},
	text_posted: { /* serveur → tous */ /* … */ },
};

// autorisation de transit : ce qui a le droit de traverser le réseau
export const CHANNEL = {
	fingerprint: FINGERPRINT,
	clientEmits:    [{ name: "text_entered", version: 1 }],
	clientReceives: [{ name: "text_posted",  version: 1 }],
};
```

Chaque broker reçoit **un seul catalogue d'union** — seul endroit où les catalogues de
canaux se rencontrent, et où vivent les contrats client-only / serveur-only. Le spread ne
fusionne pas (le dernier écrase silencieusement) : garde-fou obligatoire, comparé en forme
**canonique** (`canonicalStringify`, pas `JSON.stringify`).

```javascript
// public/catalogs/catalog.js
import { SHARED as GAME }  from "./game.catalog.js";
import { SHARED as STATS } from "./stats.catalog.js";
import { canonicalStringify } from "../fw/engine.js";

for (const name of Object.keys(GAME)) {
	if (name in STATS && canonicalStringify(GAME[name]) !== canonicalStringify(STATS[name])) {
		throw new Error(`contrat "${name}" défini différemment dans deux catalogues de canal`);
	}
}
export const CATALOG = { ...GAME, ...STATS /* + contrats client-only */ };
export default CATALOG;
```

### Arborescence

```
projet/
  server.js              composition serveur (brokers, canaux, composants)
  config.js              facultatif — SEUL server.js peut l'importer
  fw/                    framework serveur — générique, jamais régénéré
    engine.js            shim : export * from "../public/fw/engine.js"
    broker.js            broker serveur (route id et session)
    channel.catalog.js   contrats du framework (connection_event)
    channels/websocket_channel.js
  catalogs/catalog.js    union : SHARED + contrats framework + serveur-only
  components/<composant serveur>.js
  tests/                 TOUS les tests, y compris ceux des composants client
  public/                racine web — miroir de la structure serveur
    index.html           composition client
    fw/engine.js         source unique du moteur
    fw/broker.js         broker client (ignore id et session)
    fw/channels/websocket_channel.js
    catalogs/            UN FICHIER PAR CANAL : SHARED + FINGERPRINT + CHANNEL
      game.catalog.js
      catalog.js         union client
    components/<composant client>.js
```

Un composant = un fichier. `fw/` ne se touche jamais. Les deux moitiés d'un canal importent
**littéralement le même** fichier de catalogue.

### Les clés d'enveloppe

- **`id`** — identifiant de session, stable à travers les reconnexions. Le canal serveur le
  **pose** sur tout entrant. Un objet serveur peut le joindre à son émission (une session
  ou un array) pour ne toucher que ces clients ; **sans `id`, diffusion à tous**. Ne part
  jamais sur le réseau.
- **`session`** — l'objet `{ data }`, joint côté serveur **par référence** (jamais cloné,
  contrairement au payload) : c'est cette référence partagée qui fait vivre l'état de
  session sans annuaire global ni couplage entre composants.

Le broker **client** ignore les deux clés.

### connection_event

Le canal serveur publie `connection_event` (contrat fourni par `fw/channel.catalog.js`,
payload `{ event, channel }`, enveloppe portant `id` et `session`) à quatre moments :
`connected`, `resumed`, `disconnected` (la session survit), `expired` (TTL écoulé —
dernière livraison où la session est lisible). C'est là qu'un composant libère ce qu'il
tenait pour une session.

### Exigences d'un canal (condensé)

**A — tout canal :** (1) c'est un objet standard, qui **reçoit son transport déjà ouvert**
par `deps` et n'a **pas de `destroy()`** ; (2) chaque moitié s'inscrit en **miroir croisé**
de `CHANNEL` — elle émet localement ce que l'autre reçoit —, validation **aux deux bouts** ;
(3) le broker local ne voit que la forme canonique, **jamais de downcast** ; (4) la
plomberie du canal reste hors catalogue, mais `id`/`session` sont **du framework**.

**B — canal pontant deux catalogues séparés** (réseau, multi-dépôt, multi-langage) :
(5) **rien ne transite avant accord** (raccordement bloquant) ; (6) décision d'alignement
normative — mêmes messages/versions, **égalité des schémas des révisions partagées** en
forme canonique, **règle directionnelle** `récepteur ≥ émetteur` ; on valide toujours contre
**son propre** catalogue, jamais celui du pair ; (7) upcast vers le `latest` local.

**C — sessions** (optionnel, sauf la règle 11 dès qu'un canal porte des sessions) :
(8) sessions à token survivant `sessionTtlMs` ; (9) le canal **peuple** `id`/`session` et
traduit un `id` d'émission en sockets ; (10) il publie `connection_event` ;
(11) **grille d'admission `allowMessage`** (§6).

**Garanties** : tant que la connexion vit, TCP garantit livraison et ordre. Les messages
émis pendant une coupure sont **perdus** (warning), sans tampon ni rejeu ; la **session**
survit et c'est aux composants de rejouer l'état applicatif au retour.

### Montage

```javascript
// index.html — on attend le handshake avant de monter ce qui parle au serveur
const broker  = new Broker(CATALOG);
const channel = new WebSocketChannel(broker, "channel_text", {
	socket:  new WebSocket("ws://localhost:3000"), // la ressource ouverte, pas son URL
	channel: CHANNEL,
});
try {
	await channel.ready(); // rejette : contrat incompatible, connexion fermée, timeout
	new ChatBox(broker, "chat", { support: document.getElementById("chat") });
} catch (error) {
	console.error("canal indisponible — " + error.message + " : composants non montés");
}
```

```javascript
// server.js
const broker  = new Broker(CATALOG);
const board   = new TextBoard(broker, "board");
const channel = new WebSocketChannel(broker, "channel_text", {
	wss: new WebSocketServer({ server }),
	channel: TEXT_CHANNEL, // importé de public/catalogs/text.catalog.js
});
```

---

## 6. Authentification et admission

- **`allowMessage`** — dès qu'un canal porte des sessions, il appelle
  `session.allowMessage?.(name, payload)` avant de publier **chaque entrant** (sous sa
  forme canonique) ; si le retour est `false`, il **jette la trame et loggue un warning**
  (canal, `id`, nom du message). **Absent ⇒ autorisé** — il faut bien que le login passe ;
  seul défaut permissif du canal.
- La grille reçoit **nom + payload** : elle autorise donc au grain du membre `oneOf`.
  `this === session`, et l'identité vit dans `session.data` — l'utilisateur est connu
  **sans qu'on le passe**.
- **Le composant d'auth** — un **par canal**, filtrant `connection_event` sur
  `payload.channel` :

```javascript
handleMessage(message) {
	if (message.login) {
		const user = this.#authenticate(message.login);
		if (user !== null) message.session.data.user = user; // visible de tous, dès maintenant
		this.broker.publish(this, { login_result: { granted: user !== null }, id: message.id });
	}
}
```

- au `connected`, il installe `session.allowMessage` **de façon synchrone** (au minimum un
  *deny-sauf-login*), quitte à la resserrer ensuite : **jamais** de fenêtre où
  `allowMessage` est `undefined` sous une trame pipelinée ;
- il **re-valide au `resumed`** (l'utilisateur a pu être banni entre-temps) ;
- il libère à l'`expired`.

`allowMessage` ne décide que de l'**admission**. L'autorisation **fine** d'une action reste
au composant qui l'exécute, par une convention libre (p. ex. `session.allowAction(...)`),
**hors** framework.

---

## 7. Faire évoluer un contrat

**Régime de croisière : la révision** — snapshot complet, jamais un diff. Seul l'émetteur
qui produit la nouvelle donnée est touché ; les émetteurs en retard continuent d'émettre
leur révision, le broker complète.

```javascript
text_posted: {
	versions: { 1: { revisions: {
		0: { fields: { text: {…}, ref: {…} } },
		1: { fields: {                                   // snapshot complet
			text:  { type: "string", required: true },
			ref:   { type: "string", required: true },
			color: { type: "string", required: true, default: "black" },
		} },
	} } },
}
```

Le `default` **ne sert qu'à l'upcast, jamais à la validation** : un émetteur déclaré en
révision 1 qui omettrait `color` est **rejeté**. Le défaut ne comble que les émetteurs
d'une révision *antérieure* — d'où l'absence de `default` en révision 0.

**Le cas rare : la version** (renommer, supprimer, changer le sens d'un champ), gravée avec
un upcaster **déclaratif** — vocabulaire fermé `rename` / `defaults` / `remove`, pas du
code, interprété par le moteur et prouvé par les exemples. Le `.` y est le séparateur de
chemin.

```javascript
2: {
	upcastFrom: {
		rename:   { "color": "style.color" },
		defaults: { "style.font-size": "16px", "style.font-weight": "normal" },
	},
	revisions: { 0: { fields: { /* … style: { type: "object", fields: {…} } */ } } },
},
```

Le broker met d'abord l'émission en forme canonique de sa version, puis **chaîne** les
upcasts (v→v+1→…→cible) pour servir un abonné plus récent. **Jamais de downcast** : la
gouvernance de migration est donc **« les abonnés d'abord, l'émetteur en dernier »**. Un
abonné resté en arrière n'est plus servi — le broker le signale et le saute, la casse est
visible dans les logs, jamais silencieuse.

**À travers un canal**, la règle directionnelle se projette ainsi :
- **client→serveur** : un client **en retard passe** (le serveur upcaste l'entrant). Graver
  une révision côté serveur **n'éjecte aucun client**.
- **serveur→client** : le client doit être **au moins aussi à jour**. Faire avancer un
  message serveur→client exige que les clients le soient déjà ; sinon, refus **au
  raccordement**, proprement.

---

## 8. Tester

### Tests par nodejs seul

Un fichier de test par composant (dans /tests) : un **vrai broker**, une **sonde** abonnée à ses
émissions, et l'injection des `examples` du catalogue. Aucun monkey-patch : on construit
l'objet comme le fait `server.js` / `index.html`, avec d'autres `deps`.

```javascript
class Probe {
	instanceName;
	received = [];
	constructor(instanceName) { this.instanceName = instanceName; }
	handleMessage(message) { this.received.push(message); }
}

const broker = new Broker(CATALOG);
const memory = new PlatesMemory(broker, "memory");
const probe  = new Probe("probe");
broker.register(probe, [{ name: "click", version: 1 }], [{ name: "plates_count", version: 1 }]);

broker.publish(probe, { click: {} });
console.assert(probe.received[0]?.plates_count?.count === 1, "un clic = une assiette");
```

**Composants client** : motif **littéralement identique**. Deux différences seulement —
la sonde **joue le rôle du canal** après handshake (elle émet les `clientReceives` du
`CHANNEL` et s'abonne aux `clientEmits`, donc tout ce qu'elle injecte est validé par le
vrai broker), et `jsdom` (dépendance de dev, pas de build) fournit un `document` réel, si
bien que le test prouve aussi le **rendu**.

```javascript
const dom = new JSDOM(`<!DOCTYPE html><div id="button"></div><div id="stack"></div>`);
globalThis.document = dom.window.document;

const broker = new Broker(CATALOG);
const probe  = new Probe("probe");
broker.register(probe,
	[{ name: "plates_count", version: 1 }],  // la sonde émet ce que le serveur émettrait
	[{ name: "click", version: 1 }]);        // et reçoit ce que le client émet

new PlatesButton(broker, "button", { support: document.getElementById("button") });
const stack = document.getElementById("stack");
new PlatesStack(broker, "stack", { support: stack });

document.querySelector("button").click();
console.assert(probe.received[0]?.click !== undefined, "un clic émet le message click");

broker.publish(probe, { plates_count: { count: 3 } });
console.assert(stack.textContent.includes("3 assiette"), "la pile affiche le compte reçu");
```

Deux règles pour que ces tests vieillissent bien : **un scénario = un monde** (chaque
scénario construit son broker, sa sonde, son support), et les assertions portent sur ce que
**voit l'utilisateur** (textes, comptes), jamais sur la structure HTML fine.


### Tests avec visualisation web

Un fichier de test par composant dans /public/tests.  

**composants serveur**: le test en nodejs créera un accès comme le fait serveur.js vers /public
ainsi on accédera par http://localhost:PORT/tests/<composant>.html
Le html créera une interface où on peut jouer les scénarios en cliquant et où
on visualise les messages qui transitent sur le canal. Le canal utilise le vrai channel de l'application
mais uniquement avec un catalog réduit aux messages nécessaires au composant.

**composants client**: Le fichier HTML  sera accessible directement par un navigateur, 
pas besoin de servir en http.  /public/tests/<comoposant>.html simulera le canal pour échanger 
avec le composant testé en s'inscrivant directement au broker. Le composant est affiché à gauche
et à côté on peut lancer des scénarios et visualiser les messages échangés.

Dans les 2 cas on laisse la possibilité de rédiger un message à envoyer.




---

## 9. Check-list de conformité

- [ ] Chaque composant ne dépend que du broker — aucun import ni appel d'un autre composant.
- [ ] Signature `(broker, instanceName, deps)` ; tout le reste arrive par `deps`, ressource
      ouverte et non URL ; aucun `process.env`, aucun singleton, aucun import de config.
- [ ] `emits` / `receives` déclarés ; l'abonné ne déclare **pas** de révision.
- [ ] `register` au constructeur, `destroy()` symétrique.
- [ ] Chaque message a un émetteur **et** au moins un abonné (vérifié par `broker.audit`).
- [ ] Tout champ de tout payload est déclaré ; payload **JSON pur** ; `oneOf` à membres
      optionnels sans `default` ; aucun `.` dans un nom de champ.
- [ ] `examples` / `counterexamples` écrits ; `node test_engine.js` passe.
- [ ] Aucun nom d'instance en payload : corrélation par `ref`, ciblage par `id`.
- [ ] Serveur : login → `session.data.user` ; `allowMessage` posé **synchrone** au
      `connected`, au grain `oneOf`, re-validé au `resumed`.
- [ ] Contrats **révisés/versionnés**, jamais modifiés en place ; champ ajouté à une
      révision > 0 portant un `default`.
- [ ] Pas de build : ES natif, le fichier écrit est le fichier exécuté.
- [ ] Granularité proportionnée (`ia_spe.md`) : pas un composant par action, pas un message
      par verbe.
