Commit 450b1344 authored by Kourser's avatar Kourser
Browse files

Continuité d'activité, parties prenantes, échéances réglementaires

Trois outils qui répondent aux deux questions que le comité de direction pose
toutes les deux heures, et auxquelles rien ne répondait.

Qu'est-ce qui tourne encore ?
  Un registre d'applications métier tenu à froid — criticité, responsable,
  secours papier, durée d'interruption admissible — et leur état constaté à
  chaud. La séparation n'est pas cosmétique : la liste ne change pas pendant la
  crise, l'état change toutes les heures.

  Ce n'est pas de la supervision. Personne ne sonde le système d'information,
  quelqu'un regarde et déclare : d'où l'auteur de chaque constat, et surtout
  son âge. « Nominal, constaté il y a six heures » n'est pas « nominal », et
  c'est la fraîcheur qui fait la valeur du tableau. Sans constat, l'état est
  « inconnu » — jamais « nominal ». Le même piège que les indicateurs de
  préparation, corrigé avant qu'il se pose.

  Le vital arrêté remonte en tête, avant l'ordre alphabétique : à trois heures
  du matin on lit les premières lignes.

Qui informer, et avant quand ?
  Un registre distinct des tiers activables : on ne mobilise pas une autorité
  de contrôle, on l'informe. Confondre les deux fait qu'on appelle son assureur
  et qu'on oublie la CNIL.

  Chaque partie porte son délai et le texte qui le fonde. Sans le fondement,
  personne ne saura dans six mois pourquoi ce compte à rebours existait ni s'il
  s'applique encore. Et un message type préparé à froid, parce que le jour J on
  n'écrit pas une notification réglementaire de tête.

Le délai ne court pas depuis l'ouverture de l'outil
  C'est le point que ces tableaux ratent d'habitude. Le 72 heures du RGPD court
  depuis la prise de connaissance des faits, qui précède presque toujours
  l'ouverture de la crise dans Kastell — parfois de deux jours. Confondre les
  deux fait croire qu'il reste du temps quand il n'en reste plus.

  Kastell part du déclenchement faute de mieux, le dit franchement à l'écran,
  et permet de dater la découverte. Cet acte déplace toutes les échéances à la
  fois : motif obligatoire, journalisé, et refus d'une date postérieure au
  déclenchement — on n'apprend pas une crise après l'avoir ouverte.

  Un délai dépassé s'affiche en négatif plutôt que de disparaître. Il vaut
  mieux notifier tard que pas du tout, et le dire dans la notification.

  L'échéance la plus proche est dans la barre d'état, visible depuis n'importe
  quel écran : c'est le genre de délai qu'on rate en étant absorbé par la
  technique.

Un défaut de ma méthode, corrigé
  Deux remplacements de texte n'ont trouvé aucune correspondance et ont échoué
  en silence : les deux écrans étaient importés, compilés, jamais rendus. Les
  remplacements suivants lèvent une erreur quand le motif est absent.

557 → 577 garanties, dont le rembobinage : au rejeu, l'ERP est arrêté comme il
l'était à cet instant, et la paie n'a pas encore été constatée.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 08dd0881
Loading
Loading
Loading
Loading
+27 −0
Original line number Diff line number Diff line
@@ -411,6 +411,33 @@ domaine résolvable. En production, activez `tls_port: 443` dans
pare-feux laissent passer, et souvent le seul chemin depuis l'hôtel où la
cellule s'est repliée.

## Continuité, parties prenantes, échéances

Deux questions que le comité de direction pose toutes les deux heures, et
auxquelles aucun outil de crise ne répondait :

**« Qu'est-ce qui tourne encore ? »** Un registre d'applications métier tenu à
froid — criticité, responsable, **secours papier**, durée d'interruption
admissible — et leur état constaté à chaud. Ce n'est pas de la supervision :
personne ne sonde le SI, quelqu'un regarde et déclare. D'où l'auteur de chaque
constat et surtout son âge — **« nominal, constaté il y a six heures » n'est pas
« nominal »**. Sans constat, l'état est `inconnu`, jamais `nominal`.

**« Qui informer, et avant quand ? »** Un registre de parties prenantes,
distinct des tiers activables : on ne les mobilise pas, on les tient informées.
Chacune avec son délai et **le texte qui le fonde** — sans lui, personne ne
saura dans six mois pourquoi ce compte à rebours existait.

Le point qui compte : **le délai ne court pas depuis l'ouverture de la crise
dans l'outil, mais depuis la prise de connaissance des faits.** Les deux
diffèrent presque toujours. Kastell part du déclenchement faute de mieux, le
dit franchement, et permet de dater la découverte — ce qui déplace toutes les
échéances à la fois, avec motif obligatoire au journal.

L'échéance la plus proche est affichée **dans la barre d'état**, depuis
n'importe quel écran : c'est le genre de délai qu'on rate en étant absorbé par
la technique.

## Canaux de diffusion et envois de masse

Chaque organisation branche **ses propres passerelles** — OVHcloud pour le SMS,
+123 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Continuité d'activité et parties prenantes à informer
--
-- Deux registres constitués à froid, versionnés comme le reste du dossier de
-- préparation ; deux états tenus à chaud, journalisés comme tout ce qui touche
-- une crise. La séparation n'est pas cosmétique : la liste des applications ne
-- change pas pendant la crise, leur état change toutes les heures.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Applications métier (à froid) ──────────────────────────────────────────
-- « Qu'est-ce qui tourne encore ? » est la question que pose le comité de
-- direction toutes les deux heures. On ne peut y répondre que si la liste
-- existait avant.
create table application_metier (
  organisation_id uuid    not null references organisation(id) on delete cascade,
  id              uuid    not null default gen_random_uuid(),
  depuis_version  integer not null,
  jusqu_a_version integer,
  supprimee       boolean not null default false,

  nom             text    not null,
  -- « vitale » : son arrêt arrête l'entreprise. Le mot est fort exprès — une
  -- liste où tout est vital ne hiérarchise rien.
  criticite       text    not null default 'importante'
                  check (criticite in ('vitale','importante','secondaire')),
  editeur         text,
  hebergement     text,
  responsable     text,
  responsable_tel text,

  -- Ce qui permet de continuer sans elle. Le champ le plus utile du registre,
  -- et celui qu'on remplit le moins.
  secours         text,
  -- Durée maximale d'interruption admissible, en heures.
  rto_heures      integer,
  donnees_sensibles boolean not null default false,
  notes           text,
  verifie_at      timestamptz,
  primary key (id, depuis_version)
);
create index application_courante_idx on application_metier (organisation_id)
  where jusqu_a_version is null;

create view application_courante as
  select * from application_metier where jusqu_a_version is null and not supprimee;

-- ── Parties prenantes à informer (à froid) ─────────────────────────────────
-- Distinctes des tiers activables : on ne les mobilise pas, on les tient
-- informées. Confondre les deux fait qu'on appelle son assureur et qu'on
-- oublie l'autorité de contrôle.
create table partie_prenante (
  organisation_id uuid    not null references organisation(id) on delete cascade,
  id              uuid    not null default gen_random_uuid(),
  depuis_version  integer not null,
  jusqu_a_version integer,
  supprimee       boolean not null default false,

  nom             text    not null,
  nature          text    not null default 'autre'
                  check (nature in ('autorite','client','partenaire','assureur',
                                    'salaries','actionnaires','presse','autre')),
  contact_nom     text,
  contact_email   text,
  contact_tel     text,

  -- Le délai qui court, et le texte qui le fonde. Sans le fondement, personne
  -- ne saura dans six mois pourquoi ce compte à rebours existait.
  delai_heures    integer,
  fondement       text,
  -- Ce qu'il faut être capable de dire, préparé à froid : le jour J on n'écrit
  -- pas une notification réglementaire de tête.
  message_type    text,
  declencheurs    text[]  not null default '{}',
  notes           text,
  verifie_at      timestamptz,
  primary key (id, depuis_version)
);
create index partie_courante_idx on partie_prenante (organisation_id)
  where jusqu_a_version is null;

create view partie_courante as
  select * from partie_prenante where jusqu_a_version is null and not supprimee;

-- ── L'heure qui fait courir les délais (à chaud) ───────────────────────────
--
-- Le délai réglementaire ne court pas depuis l'ouverture de la crise dans
-- Kastell : il court depuis la prise de connaissance des faits, qui la précède
-- souvent de plusieurs heures — parfois de plusieurs jours. Confondre les deux
-- fait croire qu'il reste du temps quand il n'en reste plus.
alter table crise add column connaissance_at timestamptz;
alter table crise add column connaissance_motif text;

comment on column crise.connaissance_at is
  'Prise de connaissance des faits. Origine des comptes à rebours réglementaires ; '
  'à défaut, la date de déclenchement.';

-- ── État constaté des applications (à chaud) ───────────────────────────────
create table constat_continuite (
  crise_id       uuid        not null references crise(id) on delete restrict,
  application_id uuid        not null,
  seq            bigint      not null,
  etat           text        not null
                 check (etat in ('nominal','degrade','arrete','inconnu')),
  detail         text,
  constate_par   uuid        references compte(id) on delete set null,
  constate_at    timestamptz not null,
  primary key (crise_id, application_id, seq)
);
create index constat_dernier on constat_continuite (crise_id, application_id, seq desc);

-- ── Informations transmises (à chaud) ──────────────────────────────────────
create table information_transmise (
  crise_id     uuid        not null references crise(id) on delete restrict,
  id           uuid        not null,
  partie_id    uuid        not null,
  seq          bigint      not null,
  canal        text        not null,
  resume       text        not null,
  transmis_par uuid        references compte(id) on delete set null,
  transmis_at  timestamptz not null,
  primary key (crise_id, id)
);
create index information_partie on information_transmise (crise_id, partie_id, seq desc);
+217 −0
Original line number Diff line number Diff line
import { randomUUID } from "node:crypto";
import type { Sql } from "../db/client.js";
import { enregistrer, type Contexte } from "../noyau/enregistrer.js";

/**
 * Continuité d'activité et parties prenantes (§6.12).
 *
 * Deux questions que le comité de direction pose toutes les deux heures, et
 * auxquelles aucun outil de crise ne répond aujourd'hui :
 *
 *   — Qu'est-ce qui tourne encore ?
 *   — Qui doit être informé, et dans combien de temps est-ce trop tard ?
 *
 * La première se répond par des constats, la seconde par des échéances. Ni
 * l'une ni l'autre ne se répond si les registres n'existaient pas avant.
 */

export type EtatApplication = "nominal" | "degrade" | "arrete" | "inconnu";

/** Au-delà, un constat n'est plus un état : c'est un souvenir. */
export const FRAICHEUR_CONSTAT_MINUTES = 120;

export interface LigneContinuite {
  application_id: string;
  nom: string;
  criticite: "vitale" | "importante" | "secondaire";
  secours: string | null;
  rto_heures: number | null;
  responsable: string | null;
  responsable_tel: string | null;
  donnees_sensibles: boolean;
  etat: EtatApplication;
  detail: string | null;
  constate_at: Date | null;
  constate_par_nom: string | null;
  /** Minutes écoulées depuis le constat, ou `null` s'il n'y en a jamais eu. */
  age_minutes: number | null;
  /** Vrai quand le constat est trop vieux pour être présenté comme un état. */
  perime: boolean;
}

/**
 * L'état de continuité, applications vitales d'abord.
 *
 * Le tri n'est pas alphabétique : à trois heures du matin on lit les premières
 * lignes, et ce sont celles-là qui doivent porter le vital arrêté.
 */
export async function etat(
  sql: Sql, criseId: string, organisationId: string, jusquASeq?: number,
): Promise<LigneContinuite[]> {
  const lignes = await sql<(Omit<LigneContinuite, "age_minutes" | "perime">)[]>`
    select a.id as application_id, a.nom, a.criticite, a.secours, a.rto_heures,
           a.responsable, a.responsable_tel, a.donnees_sensibles,
           coalesce(d.etat, 'inconnu') as etat,
           d.detail, d.constate_at, c.nom as constate_par_nom
      from application_courante a
      left join lateral (
        select etat, detail, constate_at, constate_par
          from constat_continuite
         where crise_id = ${criseId} and application_id = a.id
           ${jusquASeq !== undefined ? sql`and seq <= ${jusquASeq}` : sql``}
         order by seq desc limit 1
      ) d on true
      left join compte c on c.id = d.constate_par
     where a.organisation_id = ${organisationId}
     order by case a.criticite when 'vitale' then 0 when 'importante' then 1 else 2 end,
              case coalesce(d.etat, 'inconnu')
                when 'arrete' then 0 when 'degrade' then 1 when 'inconnu' then 2 else 3 end,
              a.nom`;

  return lignes.map((l) => {
    const age = l.constate_at
      ? Math.round((Date.now() - new Date(l.constate_at).getTime()) / 60000) : null;
    return {
      ...l,
      age_minutes: age,
      // « Nominal, constaté il y a six heures » n'est pas « nominal ». C'est la
      // fraîcheur qui fait la valeur du tableau, pas l'état lui-même.
      perime: age !== null && age > FRAICHEUR_CONSTAT_MINUTES,
    };
  });
}

export async function constater(
  sql: Sql, criseId: string, o: {
    applicationId: string; nom: string; etat: EtatApplication;
    detail?: string | null | undefined;
  }, ctx: Contexte,
): Promise<number> {
  return (await sql.begin(async (tx) => {
    const ev = await enregistrer(tx, {
      criseId, celluleId: null, type: "continuite.constatee",
      charge: {
        application_id: o.applicationId, nom: o.nom, etat: o.etat,
        detail: o.detail ?? null,
      },
      ctx,
    });
    return ev.seq;
  })) as number;
}

// ═══════════════════════════════════════════════════════════════════════════
// Parties prenantes et échéances
// ═══════════════════════════════════════════════════════════════════════════

export interface Echeance {
  partie_id: string;
  nom: string;
  nature: string;
  contact_nom: string | null;
  contact_email: string | null;
  contact_tel: string | null;
  delai_heures: number | null;
  fondement: string | null;
  message_type: string | null;
  /** Quand le délai expire, s'il y en a un. */
  echoit_at: Date | null;
  /** Minutes restantes. Négatif : le délai est dépassé. */
  reste_minutes: number | null;
  informe_at: Date | null;
  informe_par_nom: string | null;
  informe_canal: string | null;
  informe_resume: string | null;
}

/**
 * Ce qui reste à faire, et dans combien de temps il sera trop tard.
 *
 * L'origine des délais est la prise de connaissance des faits, pas l'ouverture
 * de la crise dans l'outil. Les deux diffèrent presque toujours, et les
 * confondre fait croire qu'il reste du temps quand il n'en reste plus.
 */
export async function echeances(
  sql: Sql, criseId: string, organisationId: string,
): Promise<{ depart_at: Date; depart_declare: boolean; parties: Echeance[] }> {
  const [crise] = await sql<{ declenchee_at: Date; connaissance_at: Date | null }[]>`
    select declenchee_at, connaissance_at from crise where id = ${criseId}`;
  if (!crise) throw new Error("Crise inconnue.");
  const depart = crise.connaissance_at ?? crise.declenchee_at;

  const lignes = await sql<(Omit<Echeance, "echoit_at" | "reste_minutes">)[]>`
    select p.id as partie_id, p.nom, p.nature, p.contact_nom, p.contact_email,
           p.contact_tel, p.delai_heures, p.fondement, p.message_type,
           i.transmis_at as informe_at, i.canal as informe_canal, i.resume as informe_resume,
           c.nom as informe_par_nom
      from partie_courante p
      left join lateral (
        select transmis_at, canal, resume, transmis_par
          from information_transmise
         where crise_id = ${criseId} and partie_id = p.id
         order by seq desc limit 1
      ) i on true
      left join compte c on c.id = i.transmis_par
     where p.organisation_id = ${organisationId}
     order by p.delai_heures nulls last, p.nom`;

  const parties = lignes.map((l) => {
    const echoit = l.delai_heures !== null
      ? new Date(depart.getTime() + l.delai_heures * 3600_000) : null;
    return {
      ...l,
      echoit_at: echoit,
      reste_minutes: echoit ? Math.round((echoit.getTime() - Date.now()) / 60000) : null,
    };
  });

  return { depart_at: depart, depart_declare: crise.connaissance_at !== null, parties };
}

export async function informer(
  sql: Sql, criseId: string, o: {
    partieId: string; nom: string; canal: string; resume: string;
  }, ctx: Contexte,
): Promise<{ informationId: string; seq: number }> {
  const informationId = randomUUID();
  return (await sql.begin(async (tx) => {
    const ev = await enregistrer(tx, {
      criseId, celluleId: null, type: "information.transmise",
      charge: {
        information_id: informationId, partie_id: o.partieId, nom: o.nom,
        canal: o.canal, resume: o.resume,
      },
      ctx,
    });
    return { informationId, seq: ev.seq };
  })) as { informationId: string; seq: number };
}

/**
 * Dater la prise de connaissance.
 *
 * Acte lourd : elle déplace toutes les échéances à la fois. D'où le motif
 * obligatoire, et le refus d'une date postérieure au déclenchement — on ne
 * prend pas connaissance d'une crise après l'avoir ouverte.
 */
export async function daterConnaissance(
  sql: Sql, criseId: string, connaissanceAt: Date, motif: string, ctx: Contexte,
): Promise<number> {
  const [crise] = await sql<{ declenchee_at: Date }[]>`
    select declenchee_at from crise where id = ${criseId}`;
  if (!crise) throw new Error("Crise inconnue.");
  if (connaissanceAt.getTime() > crise.declenchee_at.getTime()) {
    throw new Error(
      "La prise de connaissance ne peut pas être postérieure au déclenchement : "
      + "on n'apprend pas une crise après l'avoir ouverte.");
  }

  return (await sql.begin(async (tx) => {
    const ev = await enregistrer(tx, {
      criseId, celluleId: null, type: "crise.connaissance_datee",
      charge: { connaissance_at: connaissanceAt.toISOString(), motif },
      ctx,
    });
    return ev.seq;
  })) as number;
}
+25 −0
Original line number Diff line number Diff line
@@ -270,6 +270,31 @@ const projecteurs: Partial<Record<TypeEvenement, Projecteur>> = {
         and version = ${ch.version}`;
  },

  "continuite.constatee": async (tx, ev) => {
    const ch = relire("continuite.constatee", ev.charge);
    await tx`
      insert into constat_continuite (crise_id, application_id, seq, etat, detail,
                                      constate_par, constate_at)
      values (${ev.crise_id}, ${ch.application_id}, ${ev.seq}, ${ch.etat},
              ${ch.detail ?? null}, ${ev.acteur_id}, ${ev.occurred_at})`;
  },

  "information.transmise": async (tx, ev) => {
    const ch = relire("information.transmise", ev.charge);
    await tx`
      insert into information_transmise (crise_id, id, partie_id, seq, canal, resume,
                                         transmis_par, transmis_at)
      values (${ev.crise_id}, ${ch.information_id}, ${ch.partie_id}, ${ev.seq},
              ${ch.canal}, ${ch.resume}, ${ev.acteur_id}, ${ev.occurred_at})`;
  },

  "crise.connaissance_datee": async (tx, ev) => {
    const ch = relire("crise.connaissance_datee", ev.charge);
    await tx`
      update crise set connaissance_at = ${ch.connaissance_at}, connaissance_motif = ${ch.motif}
       where id = ${ev.crise_id}`;
  },

  "document.range": async (tx, ev) => {
    const ch = relire("document.range", ev.charge);
    await tx`
+91 −0
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import type { Partiel } from "@kastell/shared";
import * as dossier from "./dossier.js";
import type { TablePreparation } from "./dossier.js";

/**
 * Registres de continuité, à froid (§6.12).
 *
 * Versionnés comme le reste du dossier de préparation : la liste des
 * applications d'une organisation n'est pas la même en janvier et en octobre,
 * et une crise doit être relue avec le registre qui était en vigueur ce
 * jour-là — pas avec celui d'aujourd'hui.
 */

export interface Application {
  id: string;
  nom: string;
  criticite: "vitale" | "importante" | "secondaire";
  editeur: string | null;
  hebergement: string | null;
  responsable: string | null;
  responsable_tel: string | null;
  secours: string | null;
  rto_heures: number | null;
  donnees_sensibles: boolean;
  notes: string | null;
  verifie_at: Date | null;
}

export interface Partie {
  id: string;
  nom: string;
  nature: string;
  contact_nom: string | null;
  contact_email: string | null;
  contact_tel: string | null;
  delai_heures: number | null;
  fondement: string | null;
  message_type: string | null;
  declencheurs: string[];
  notes: string | null;
  verifie_at: Date | null;
}

type Saisie<T> = Partiel<Omit<T, "id" | "verifie_at">>;

export const applications = registre<Application>("application_metier", "l'inventaire");
export const parties = registre<Partie>("partie_prenante", "les parties prenantes");

function registre<T extends { id: string; nom: string }>(
  table: TablePreparation, quoi: string,
) {
  return {
    async lister(sql: Sql, organisationId: string, version: number | null = null): Promise<T[]> {
      return dossier.lire<T>(sql, table, organisationId, version);
    },

    async creer(
      sql: Sql, organisationId: string, saisie: Saisie<T>, auteurId: string | null,
    ): Promise<string> {
      return (await sql.begin(async (tx) => {
        const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
          `Ajout de ${String((saisie as { nom?: string }).nom ?? "une fiche")} à ${quoi}`);
        return dossier.ecrire(tx, table, organisationId, v, null,
          { ...saisie, verifie_at: new Date() } as Record<string, unknown>);
      })) as string;
    },

    async modifier(
      sql: Sql, organisationId: string, id: string,
      changements: Saisie<T>, auteurId: string | null,
    ): Promise<void> {
      await sql.begin(async (tx) => {
        const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
          `Modification dans ${quoi}`);
        await dossier.ecrire(tx, table, organisationId, v, id,
          { ...changements } as Record<string, unknown>);
      });
    },

    async retirer(
      sql: Sql, organisationId: string, id: string, auteurId: string | null,
    ): Promise<void> {
      await sql.begin(async (tx) => {
        const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
          `Retrait dans ${quoi}`);
        await dossier.supprimer(tx, table, organisationId, v, id);
      });
    },
  };
}
Loading