Commit 9108fa7b authored by Kourser's avatar Kourser
Browse files

Une machine peut proposer, une personne seule pose l'acte

Lots A1 et A2 du cahier des charges IA, la conformité du serveur MCP, et la
mention d'agent partout où l'acte s'affiche. 52 exigences sur 52 sont
désormais tenues par une garantie ; le harnais passe de 1204 à 1326.

── Proposer, ce qui n'est pas écrire (A1)

Les propositions vivent dans une table ordinaire, hors de la chaîne
d'empreintes. Le journal ne vaut que parce que personne ne peut lui faire
dire ce qui n'a pas été décidé ; une écriture « sous validation » ferait
dépendre cette valeur de l'attention d'une personne à la sixième heure,
c'est-à-dire de rien.

Valider ne promeut pas une ligne : cela rappelle consigner, actions.creer ou
points.publier — les mêmes fonctions que l'interface — au nom du valideur. Il
n'existe donc aucun second chemin d'écriture qui pourrait diverger du premier.

L'écran de relecture est construit contre le réflexe : le contenu s'affiche
dans des champs modifiables et non en lecture, il n'y a pas de « tout
valider », et le bouton dit « Poser en mon nom » parce que c'est ce qui se
passe. Douze heures d'échéance, vingt propositions ouvertes par crise, aucun
heurtoir. Le bilan de clôture compte les écartées : un taux d'écartement nul
est un signal, pas une réussite.

── La voix, sous consentement (A2)

Consentement distinct de celui de l'enregistrement, par crise, nommant le
service — et comparé au moment de servir : renommer une clé ne vaut pas
re-consentir. Sans lui, l'outil refuse en expliquant, et le journal reste
entièrement lisible.

Ce qu'un agent tire d'un enregistrement s'efface avec lui. La suppression ne
supprime pas la ligne mais la marque : aucune cascade ne se serait déclenchée,
et effacer la vidéo en gardant ce qu'une machine en avait extrait aurait été
purger en apparence.

── Ce que le serveur promet à son client

Chaque outil déclare le schéma de ce qu'il rend, et le champ est obligatoire
dans le type : un outil neuf ne peut pas l'oublier. Un schéma n'étant tenable
que si l'on maîtrise la forme, tout ce qui était reversé tel quel est
désormais projeté explicitement — cockpit, décisions, actions, points, tiers,
fiches.

Deux fuites s'y sont découvertes. L'aperçu servait le numéro et le code du
pont de conférence : de quoi s'asseoir dans la salle, qu'aucune portée ne
prétend accorder. Le dossier gelé servait les tiers verbatim, donc le nom, le
numéro et l'adresse de leurs interlocuteurs — des gens qui n'ont pas plus
consenti que ceux de l'annuaire. Le contrôle qui aurait dû le voir cherchait
les colonnes de l'annuaire du personnel dans une liste de tiers vide.

Côté transport : 405 avec « Allow » sur GET et DELETE plutôt qu'un 404 qui
fait chercher une adresse mal écrite, validation de l'en-tête « Origin », et
refus nommé d'une révision de protocole non servie.

── La mention suit l'acte, y compris hors de l'écran web

« Aucune fusion silencieuse dans un fil, une liste ou un export. » Elle
manquait à la ligne de journal d'un point publié, à la table des points, à
l'archive du dossier, au dossier rendu — le document versé à une assurance ou
à une enquête — et à toute l'application mobile.

Dans le dossier imprimé la mention est du texte et non une couleur : ce qui
n'est pas écrit ne survit pas à une photocopie. Sur téléphone le bandeau est
teal et non rouge : le rouge y appartient à la mobilisation, et ce bandeau
annonce une exposition, pas une urgence. Il passe par le cockpit, que l'écran
appelle déjà — il doit être là au premier rendu, pas après qu'on a commencé à
écrire.

── Deux dettes réglées au passage

La migration 0040 avait été complétée après avoir déjà tourné ; une migration
ne se rejoue pas, et l'ajout n'a atteint aucune base déjà migrée. 0042
rattrape, idempotente, pour un parc dont on ne connaît pas l'état.

Aucun écran ne passe plus par une boîte de dialogue du navigateur : le texte
d'un « prompt » échappe au dictionnaire et ne figure même pas au dénominateur
du taux de traduction. La règle est posée pour que le raccourci ne revienne
pas.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 09ffc4b2
Loading
Loading
Loading
Loading
Loading
+49 −0
Changes for apps/api/src/administration/diagnostic.ts: 49 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -67,7 +67,40 @@ function eicar(): Buffer {
  return Buffer.from(parts.join(""), "ascii");
}

/**
 * L'état des agents, sans appeler personne (EI-51).
 *
 * « Configuré » veut dire ici : au moins une clé vivante. Une instance qui n'en
 * a aucune n'est pas en panne, elle est dans son état par défaut — et la sonde
 * doit le dire ainsi plutôt que de compter un manque.
 */
async function sonderAgents(sql: Sql) {
  const [i] = await sql<{ coupes: boolean }[]>`
    select agents_coupes_at is not null as coupes from instance`;
  const [k] = await sql<{ vivantes: string; dernier: Date | null }[]>`
    select count(*)::text as vivantes, max(dernier_usage_at) as dernier
      from cle_integration
     where revoque_at is null and (expire_at is null or expire_at > now())
       and portees && array['agent_journal', 'agent_dossier', 'agent_transcription',
                            'agent_proposition']`;
  const [o] = await sql<{ n: string }[]>`
    select count(*)::text as n from agent_acces_crise where ferme_at is null`;
  const vivantes = Number(k?.vivantes ?? 0);
  const debut = Date.now();
  return {
    configures: vivantes > 0,
    sonde: {
      ok: !i?.coupes,
      ms: Date.now() - debut,
      valeur: `${vivantes} clé(s), ${o?.n ?? 0} crise(s) ouverte(s)`
        + (k?.dernier ? `, dernière lecture le ${k.dernier.toISOString()}` : ", jamais lue"),
      erreur: "Coupés sur toute l'instance depuis la console root.",
    },
  };
}

async function sonder(env: Env, sql: Sql): Promise<Sonde[]> {
  const agents = await sonderAgents(sql);
  const [base, objet, visio, scan, temps] = await Promise.all([
    borner(async () => {
      const [v] = await sql<{ v: string }[]>`select version() as v`;
@@ -131,6 +164,22 @@ async function sonder(env: Env, sql: Sql): Promise<Sonde[]> {
      + "ce qui est honnête, mais laisse passer. La composition démarre ClamAV par "
      + "défaut ; s'il vient de démarrer, laissez-lui le temps de télécharger ses "
      + "signatures. Sinon, vérifiez CLAMAV_HOST."),
    // ── EI-51 ─────────────────────────────────────────────────────────────
    //
    // Une sonde d'un genre particulier, et il faut le dire : les autres
    // appellent un service pour savoir s'il répond. Celle-ci n'appelle
    // personne, parce que Kastell n'est jamais client d'un modèle (EI-11) —
    // elle rapporte l'état des interrupteurs et la dernière fois qu'un agent
    // s'est manifesté.
    //
    // Elle figure au même rang qu'un canal de diffusion en panne parce que
    // c'est le même genre de fait : quelque chose de branché ne fonctionne
    // plus. Ce qu'elle ne doit jamais devenir, c'est une alarme — aucune
    // fonction du produit ne dépend d'un agent, et un « rouge » ici ferait
    // chercher une panne là où il n'y a qu'un silence.
    faire("agents", "Agents", agents.configures, agents.sonde,
      "Aucun agent n'est branché sur cette instance, et rien ne l'exige : la "
      + "cellule conduit la crise sans qu'aucun n'ait jamais lu une ligne."),
    faire("diffusion", "Temps réel", true, temps,
      "Le flux temps réel est dégradé : chaque écran se remet à jour à l'action, "
      + "sans propagation immédiate. Vérifiez REDIS_URL."),
+159 −0
Changes for apps/api/src/db/migrations/0040_propositions.sql: 159 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Ce qu'un agent propose, et ce qu'il entend (EI-17 à EI-21, EI-27 à EI-32,
-- EI-37 à EI-40).
--
-- Hors de la chaîne d'empreintes, et c'est tout le propos
--   Le journal ne vaut que parce que personne ne peut lui faire dire ce qui
--   n'a pas été décidé. Y laisser écrire une machine, même « sous validation »,
--   reviendrait à faire dépendre cette valeur de l'attention d'une personne
--   fatiguée à la sixième heure. Les propositions vivent donc ici, dans une
--   table ordinaire, sans hachage et sans séquence : elles ne sont pas des
--   faits de la crise tant que quelqu'un ne les a pas reprises à son compte.
--
--   Une proposition validée n'est pas « promue » : elle est rejouée par le
--   chemin humain normal, au nom du valideur. C'est pourquoi il n'y a aucun
--   pont entre cette table et « evenement » — seulement une référence, après
--   coup, vers la séquence que le valideur a produite.
--
-- Ce qui expire, et pourquoi
--   Une liste qui s'allonge sans fin devient un bruit que plus personne ne
--   regarde, et c'est la pire des deux issues : on croit avoir un dispositif
--   de relecture, on a un fond d'écran. D'où une échéance sur chaque
--   proposition, et un plafond par crise au-delà duquel l'agent est refusé.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Une quatrième origine au journal (EI-17) ───────────────────────────────
--
-- Elle n'est produite par aucun chemin humain. Elle existe pour que rien ne
-- puisse être écrit « de la part » d'un agent sans se déclarer comme tel, et
-- sans nommer la personne qui en répond.
alter table evenement drop constraint evenement_origine_check;
alter table evenement add constraint evenement_origine_check
  check (origine in ('humaine', 'automatique', 'externe', 'mobile', 'agent'));

-- EI-18 : un agent n'a pas de compte. Sans mandant nommé, l'écriture est
-- refusée par la base — pas par la couche qui écrit, qui peut être contournée.
alter table evenement add constraint evenement_agent_mandant check (
  origine <> 'agent' or charge ->> 'mandant_id' is not null);

-- La portée « proposer » rejoint les portées d'agent déjà déclarées.
alter table cle_integration drop constraint agent_declare_et_borne;
alter table cle_integration add constraint agent_declare_et_borne check (
  not (portees && array['agent_journal', 'agent_dossier', 'agent_transcription',
                        'agent_proposition'])
  or (agent_service is not null and agent_exploitant is not null
      and agent_pays is not null and expire_at is not null));

-- ── Les propositions (EI-27) ───────────────────────────────────────────────
create table proposition (
  crise_id     uuid        not null references crise(id) on delete cascade,
  id           uuid        not null default gen_random_uuid(),
  cle_id       uuid        not null references cle_integration(id) on delete cascade,
  -- EI-18 : la personne au nom de qui la clé a été délivrée. Elle en répond
  -- comme du reste ; c'est elle que le journal nommera si la proposition est
  -- validée.
  mandant_id   uuid        references compte(id) on delete set null,
  cellule_id   uuid        references cellule(id) on delete set null,
  genre        text        not null
                 check (genre in ('main_courante', 'action', 'point_situation')),
  charge       jsonb       not null,
  -- EI-19 : déclarés par le client, invérifiables, et l'interface le dit.
  -- Les stocker quand même : ce qui est vérifiable, c'est la clé et le
  -- mandant, mais une mention déclarative reste utile au retour d'expérience.
  service      text        not null,
  modele       text,
  -- EI-39 : ce qu'un agent tire d'un enregistrement s'efface avec lui. La
  -- référence est nue — pas de clé étrangère — parce que la suppression d'un
  -- enregistrement ne supprime pas sa ligne, elle la marque : une cascade ne
  -- se déclencherait jamais, et donnerait l'illusion d'une purge.
  enregistrement_id uuid,
  depose_at    timestamptz not null default now(),
  -- EI-30 : non traitée, elle expire, et son expiration se voit.
  expire_at    timestamptz not null,
  validee_at   timestamptz,
  validee_par  uuid        references compte(id) on delete set null,
  -- La séquence que le valideur a produite : de quoi remonter de la
  -- proposition au fait, et du fait à la proposition (EI-52).
  validee_seq  bigint,
  -- Validée telle quelle, ou corrigée d'abord (EI-28). La distinction est ce
  -- qui permet de mesurer si la relecture est réelle.
  corrigee     boolean     not null default false,
  ecartee_at   timestamptz,
  ecartee_par  uuid        references compte(id) on delete set null,
  motif_ecart  text,
  primary key (crise_id, id),
  -- Une proposition a une issue, ou aucune. Jamais les deux.
  constraint proposition_issue_unique check (
    validee_at is null or ecartee_at is null)
);

create index proposition_ouverte on proposition (crise_id, expire_at)
  where validee_at is null and ecartee_at is null;
create index proposition_cle on proposition (cle_id);

-- ── Le consentement aux transcriptions (EI-37) ─────────────────────────────
--
-- Distinct de celui de l'enregistrement, donné après coup, et par crise. Une
-- personne qui accepte d'être enregistrée pour la relecture interne n'a pas
-- accepté que sa voix parte chez un exploitant de modèle : ce sont deux
-- questions, elles se posent deux fois.
create table agent_consentement_transcription (
  crise_id   uuid        not null references crise(id) on delete cascade,
  cle_id     uuid        not null references cle_integration(id) on delete cascade,
  -- Le service tel qu'il était déclaré au moment du consentement. Renommer la
  -- clé ne vaut pas re-consentir : on compare, et on refuse si cela diverge.
  service    text        not null,
  donne_par  uuid        references compte(id) on delete set null,
  donne_at   timestamptz not null default now(),
  retire_par uuid        references compte(id) on delete set null,
  retire_at  timestamptz,
  primary key (crise_id, cle_id)
);

-- ── Ce qu'un agent a écouté (EI-40) ────────────────────────────────────────
--
-- La trace générale des appels dit « il a lu une transcription ». Les personnes
-- présentes à cette réunion-là ont besoin de le savoir sans lire une trace
-- d'exploitation : c'est de leur voix qu'il s'agit.
create table agent_lecture_enregistrement (
  crise_id          uuid        not null,
  enregistrement_id uuid        not null,
  cle_id            uuid        not null references cle_integration(id) on delete cascade,
  service           text        not null,
  premiere_at       timestamptz not null default now(),
  derniere_at       timestamptz not null default now(),
  lectures          integer     not null default 1,
  primary key (crise_id, enregistrement_id, cle_id),
  foreign key (crise_id, enregistrement_id)
    references enregistrement (crise_id, id) on delete cascade
);

-- ── EI-20 : la mention suit l'acte partout où il s'affiche ─────────────────
--
-- Sans elle, une entrée validée depuis une proposition se lirait exactement
-- comme une phrase écrite par quelqu'un. C'est la fusion silencieuse que le
-- cahier interdit — et c'est celle qui arriverait par défaut, puisque l'acte
-- est bien posé par une personne.
create or replace view main_courante as
select
  e.id,
  e.crise_id,
  e.cellule_id,
  e.seq,
  e.occurred_at,
  e.saisi_at,
  e.acteur_id,
  e.origine,
  e.type = 'main_courante.rectifiee'          as est_rectification,
  e.charge ->> 'genre'                        as genre,
  e.charge ->> 'texte'                        as texte,
  coalesce(e.charge ->> 'diffusion', 'cellule') as diffusion,
  (e.charge ->> 'rectifie_seq')::bigint       as rectifie_seq,
  (e.charge ->> 'pour_le_compte_de')::uuid    as pour_le_compte_de,
  coalesce(e.charge -> 'liens', '[]'::jsonb)  as liens,
  e.empreinte,
  e.charge -> 'propose_par'                   as propose_par
from evenement e
where e.type in ('main_courante.consignee', 'main_courante.rectifiee');

alter table action add column propose_par jsonb;
+28 −0
Changes for apps/api/src/db/migrations/0041_agents_exercice.sql: 28 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- S'entraîner avec un agent, et sans (EI-50).
--
-- Une organisation qui compte sur un agent doit l'avoir éprouvé en exercice,
-- comme le reste — et avoir éprouvé la crise sans lui, parce que le jour où le
-- service tombe, la cellule ne doit pas découvrir qu'elle en avait pris
-- l'habitude.
--
-- D'où deux règles qui se ressemblent et ne disent pas la même chose :
--
--   Une clé d'agent d'exercice ne s'ouvre sur aucune crise réelle. Le produit
--   sépare déjà l'exercice du réel pour les clés d'intégration ; il n'y a
--   aucune raison que la séparation s'arrête aux agents, et une raison de plus
--   qu'elle tienne — un agent d'entraînement lirait sinon de vraies décisions.
--
--   Une proposition retient si elle est née d'un exercice. Sans cette marque,
--   le bilan de retour d'expérience mêlerait ce qu'un agent a apporté en
--   entraînement à ce qu'il a apporté en crise, et une organisation croirait
--   mesurer son utilité réelle.
-- ═══════════════════════════════════════════════════════════════════════════

alter table proposition
  add column exercice boolean not null default false;

-- Posée à l'insertion depuis la crise plutôt que jointe à la lecture : une
-- crise d'exercice peut être purgée, et le bilan doit rester lisible après.
comment on column proposition.exercice is
  'EI-50 : née dans un exercice. Recopié de la crise au dépôt, pour survivre à sa purge.';
+41 −0
Changes for apps/api/src/db/migrations/0042_rattrapage_mention_agent.sql: 41 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Rattrapage : la mention d'agent sur la main courante et les actions (EI-20).
--
-- Pourquoi ce fichier existe
--   Ces deux changements ont d'abord été ajoutés à la fin de « 0040 », après
--   que celle-ci avait déjà tourné. Une migration ne se rejoue pas (EX-30) :
--   la base l'avait notée comme appliquée, et l'ajout n'a jamais atteint
--   aucune instance déjà migrée. La lecture de la main courante y échouait
--   ensuite sur une colonne absente, sans que rien ne relie la panne à sa
--   cause.
--
--   Le fichier reste intentionnellement redondant avec « 0040 » plutôt que de
--   la vider : « 0040 » décrit ce que le lot A1 installe, et se lit d'un bloc.
--   Les deux instructions ci-dessous sont sans effet là où elles ont déjà eu
--   lieu — c'est la seule forme de rattrapage qui vaille pour un parc dont on
--   ne connaît pas l'état.
-- ═══════════════════════════════════════════════════════════════════════════

create or replace view main_courante as
select
  e.id,
  e.crise_id,
  e.cellule_id,
  e.seq,
  e.occurred_at,
  e.saisi_at,
  e.acteur_id,
  e.origine,
  e.type = 'main_courante.rectifiee'          as est_rectification,
  e.charge ->> 'genre'                        as genre,
  e.charge ->> 'texte'                        as texte,
  coalesce(e.charge ->> 'diffusion', 'cellule') as diffusion,
  (e.charge ->> 'rectifie_seq')::bigint       as rectifie_seq,
  (e.charge ->> 'pour_le_compte_de')::uuid    as pour_le_compte_de,
  coalesce(e.charge -> 'liens', '[]'::jsonb)  as liens,
  e.empreinte,
  e.charge -> 'propose_par'                   as propose_par
from evenement e
where e.type in ('main_courante.consignee', 'main_courante.rectifiee');

alter table action add column if not exists propose_par jsonb;
+14 −0
Changes for apps/api/src/db/migrations/0043_point_propose_par.sql: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- La mention d'agent sur le point de situation (EI-20).
--
-- « Partout où elle s'affiche, une contribution d'agent se distingue à l'œil
-- de ce qu'une personne a écrit. Aucune fusion silencieuse dans un fil, une
-- liste ou un export. »
--
-- La main courante et les actions la portaient déjà ; le point de situation,
-- non — et c'est celui des trois qui sort le plus de l'organisation, puisqu'il
-- est écrit pour la direction. Une trame soufflée par une machine et publiée
-- telle quelle s'y lisait exactement comme une phrase du directeur de crise.
-- ═══════════════════════════════════════════════════════════════════════════

alter table point_situation add column if not exists propose_par jsonb;
Loading