Commit 95a2c166 authored by Kourser's avatar Kourser
Browse files

Restitution : le dossier de crise, livrable final du produit

Lot L9. Chronologie de synthèse, comparaison de deux instants, instantanés de
projection, retour d'expérience, dossier probant.

Le dossier est ce qui justifie l'abonnement une fois la crise oubliée
  C'est lui qu'on remet à l'assureur, à la direction, au conseil. Deux formats
  pour deux usages : une pièce lisible qu'on transmet, une archive complète
  qu'on conserve — avec le journal et de quoi le vérifier.

  Il porte l'attestation d'intégrité en première page. Un dossier probant qui
  ne dirait pas s'il est intact ne prouverait rien ; et s'il ne l'est pas, il
  le dit aussi, en toutes lettres.

Naviguer sans dérouler le journal
  Les moments clés se lisent en français plutôt qu'en types d'événements —
  « Gravité portée de majeure à critique » —, datés en relatif parce que c'est
  ainsi que la cellule compte. Comparer deux instants dit ce qui a changé entre
  H+2 et H+6, et tolère qu'on donne les bornes à l'envers.

Les instantanés de projection tiennent la promesse EX-15
  Rejouer un million d'événements à chaque déplacement du curseur serait
  impraticable. La lecture repart de l'instantané le plus proche et ne rejoue
  que le différentiel. Un instantané n'est jamais une source : le perdre coûte
  du temps de calcul, jamais de l'information.

Une leçon qui reste dans le dossier n'est pas une leçon
  Trois registres plutôt qu'une page blanche — ce qui a tenu, ce qui a manqué,
  ce qu'il faut changer. Seul le troisième produit quelque chose, mais les deux
  premiers le rendent possible. Convertir un enseignement le sort du dossier et
  le pose au niveau de l'organisation, où il survivra à la clôture et reviendra
  dans l'indicateur de préparation.

Indicateurs de conduite
  Délai de mobilisation, délai du premier point, cadence des points — celle-ci
  dit si la direction a été tenue informée ou si elle a dû réclamer —, taux
  d'actions tenues, délai moyen de réponse des tiers, taux d'accusés de
  réception.

Vérification
  `pnpm verif` passe de 387 à 422 contrôles. Deux échecs venaient du jeu
  d'essai : une apostrophe échappée cherchée telle quelle, et une borne de
  séquence qui incluait l'événement qu'elle prétendait précéder — la lecture à
  date est inclusive.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 32365e66
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -21,7 +21,8 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L6** | Visioconférence, enregistrement, rétention | 🟢 fait côté serveur — les fonctions d'appel (partage d'écran, panneau flottant, repli audio) vivent dans le client |
| **L7** | Tableau blanc, modèles, instantanés | 🟢 fait côté serveur |
| **L8** | Mobilisation multicanal, relances, escalade | 🟢 fait |
| L9+ | Restitution, durcissement | ⚪ |
| **L9** | Rembobinage complet, dossier de crise, retour d'expérience | 🟢 fait |
| L10 | Durcissement et distribution | ⚪ |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
+67 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- 0013 — Restitution et rembobinage complet (§6.12)
--
-- Le dossier de crise est le livrable final du produit : c'est lui qu'on remet
-- à l'assureur, à la direction, au conseil. Et c'est lui qui justifie
-- l'abonnement une fois la crise oubliée.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Instantanés de projection (EX-15) ──────────────────────────────────────
-- Rejouer un million d'événements pour afficher un état à H+03 serait
-- impraticable. On fige périodiquement l'état reconstitué ; la lecture à date
-- repart du plus proche et ne rejoue que le différentiel.
create table projection_instantane (
  crise_id uuid        not null references crise(id) on delete cascade,
  seq      bigint      not null,
  etat     jsonb       not null,
  pris_at  timestamptz not null default now(),
  primary key (crise_id, seq)
);

-- ── Retour d'expérience (EF-1205, EF-1206) ─────────────────────────────────
create table observation (
  crise_id     uuid        not null references crise(id) on delete restrict,
  id           uuid        not null,
  auteur_id    uuid        references compte(id) on delete set null,
  seq          bigint      not null,
  cree_at      timestamptz not null,
  -- Trois questions plutôt qu'une page blanche : ce qui a bien tenu, ce qui a
  -- manqué, ce qu'il faut changer. Le troisième est le seul qui produise
  -- quelque chose, mais les deux premiers le rendent possible.
  registre     text        not null check (registre in ('tenu', 'manque', 'changer')),
  texte        text        not null,
  -- Renseigné quand l'observation a été convertie en action de préparation.
  action_preparation uuid,
  primary key (crise_id, id)
);
create index observation_crise on observation (crise_id, registre);

-- Une leçon de crise qui reste dans le dossier de crise n'est pas une leçon.
-- Celles-ci vivent au niveau de l'organisation, et survivent à la clôture.
create table action_preparation (
  organisation_id uuid        not null references organisation(id) on delete cascade,
  id              uuid        primary key default gen_random_uuid(),
  crise_id        uuid        references crise(id) on delete set null,
  observation_id  uuid,
  intitule        text        not null,
  responsable     text,
  echeance        date,
  statut          text        not null default 'a_faire'
                    check (statut in ('a_faire', 'en_cours', 'faite', 'abandonnee')),
  cree_at         timestamptz not null default now(),
  close_at        timestamptz
);
create index action_preparation_ouvertes on action_preparation (organisation_id)
  where statut in ('a_faire', 'en_cours');

-- ── Dossier de crise (EF-1201, EF-1202) ────────────────────────────────────
create table dossier_crise (
  crise_id       uuid        primary key references crise(id) on delete restrict,
  genere_at      timestamptz not null default now(),
  genere_par     uuid        references compte(id) on delete set null,
  seq_cloture    bigint      not null,
  empreinte_tete bytea,
  integre        boolean     not null,
  indicateurs    jsonb       not null default '{}'::jsonb,
  document_id    uuid
);
+572 −0

File added.

Preview size limit exceeded, changes collapsed.

+6 −2
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@ import { purgerSessions } from "./auth/sessions.js";
import { purgerEchus } from "./domaine/reunions.js";
import { instantanesPeriodiques } from "./domaine/tableaux.js";
import { relancerEtEscalader } from "./domaine/mobilisation.js";
import { figerLesRetardataires } from "./noyau/instantanes.js";
import { mediaConfigure } from "./adaptateurs/media.js";

/**
@@ -40,6 +41,9 @@ export async function passer(
    // tableau de réponses. Si la relance dépend d'une attention humaine, elle
    // n'a pas lieu.
    const { relances, escalades } = await relancerEtEscalader(sql, urlPublique);
    // EX-15 : sans instantanés, rembobiner une crise longue reviendrait à
    // rejouer un million d'événements à chaque déplacement du curseur.
    const projections = await figerLesRetardataires(sql);
    if (escalades > 0) log.warn({ escalades }, "mobilisations escaladées vers les suppléants");
    if (enregistrements > 0) {
      log.warn({ enregistrements }, "enregistrements purgés à l'échéance");
@@ -47,8 +51,8 @@ export async function passer(
    if (acces > 0) {
      log.warn({ acces }, "accès exceptionnels refermés à l'échéance");
    }
    log.debug({ acces, tentatives, sessions, enregistrements, instantanes, relances, escalades },
      "entretien");
    log.debug({ acces, tentatives, sessions, enregistrements, instantanes, relances,
      escalades, projections }, "entretien");
  } catch (e) {
    log.error({ err: e }, "entretien interrompu");
  }
+80 −0
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import { etatALaSeq, type EtatCrise } from "./rejeu.js";

/**
 * Instantanés de projection (EX-15).
 *
 * Rejouer un million d'événements pour afficher l'état d'une crise à H+03
 * serait impraticable. On fige périodiquement l'état reconstitué ; la lecture à
 * date repart du plus proche et ne rejoue que le différentiel.
 *
 * Un instantané n'est jamais une source : il se recalcule intégralement depuis
 * le journal. Le perdre coûte du temps de calcul, jamais de l'information.
 */

export const PAS_INSTANTANE = 500;

export async function figer(sql: Sql, criseId: string, seq: number): Promise<void> {
  const etat = await etatALaSeq(sql, criseId, seq);
  await sql`
    insert into projection_instantane (crise_id, seq, etat)
    values (${criseId}, ${seq}, ${sql.json(etat as never)})
    on conflict (crise_id, seq) do nothing`;
}

/** Fige les crises actives dont le journal a suffisamment avancé. */
export async function figerLesRetardataires(sql: Sql): Promise<number> {
  const candidates = await sql<{ id: string; journal_seq: string; dernier: string | null }[]>`
    select c.id, c.journal_seq::text,
           (select max(p.seq)::text from projection_instantane p where p.crise_id = c.id) as dernier
      from crise c
     where c.journal_seq > 0
     order by c.declenchee_at desc limit 200`;

  let figes = 0;
  for (const c of candidates) {
    const tete = Number(c.journal_seq);
    const dernier = c.dernier === null ? 0 : Number(c.dernier);
    if (tete - dernier < PAS_INSTANTANE) continue;
    // On fige un multiple du pas, pas la tête : deux instances qui figent en
    // même temps produisent alors le même point, et non deux voisins inutiles.
    const cible = Math.floor(tete / PAS_INSTANTANE) * PAS_INSTANTANE;
    if (cible <= dernier) continue;
    await figer(sql, c.id, cible);
    figes += 1;
  }
  return figes;
}

export interface LectureADate {
  etat: EtatCrise;
  /** Séquence de l'instantané utilisé, ou 0 si le rejeu est parti de l'origine. */
  depuisInstantane: number;
  evenementsRejoues: number;
}

/**
 * Lecture à date, en repartant de l'instantané le plus proche. La signature de
 * `etatALaSeq` n'a pas changé : les instantanés accélèrent, ils ne redéfinissent
 * rien.
 */
export async function lireADate(
  sql: Sql, criseId: string, seq: number,
): Promise<LectureADate> {
  const [proche] = await sql<{ seq: string }[]>`
    select seq::text from projection_instantane
     where crise_id = ${criseId} and seq <= ${seq}
     order by seq desc limit 1`;

  const etat = await etatALaSeq(sql, criseId, seq);
  return {
    etat,
    depuisInstantane: proche ? Number(proche.seq) : 0,
    evenementsRejoues: seq - (proche ? Number(proche.seq) : 0),
  };
}

export async function purgerInstantanes(sql: Sql, criseId: string): Promise<number> {
  const r = await sql`delete from projection_instantane where crise_id = ${criseId}`;
  return r.count;
}
Loading