Concepts

Manifest prévisionnel

Sceller à l'ouverture la liste des opérations qui DEVRAIENT se passer dans la session. Comparer à la clôture avec ce qui s'est réellement passé. Émettre un verdict cryptographique.

Pourquoi

Sans manifest, un bundle de preuve atteste que X actions ont eu lieu. Avec manifest, il atteste que les bonnes X actions ont eu lieu — ni plus, ni moins, ni autre chose. C'est ce qui distingue une intention exécutée d'une exécution arbitraire.

Concrètement, le bénéficiaire reçoit deux mails DKIM-signés autour d'une session avec manifest :

  1. Un mail d'ouverture avec la liste lisible des opérations + planned_root_hash
  2. Un mail de clôture avec le verdict + planned_root_hash et executed_root_hash côte à côte

Conservés dans une boîte tierce, ils permettent de prouver l'intégrité du plan sans accès à notre base.

Trois hashes, à ne pas confondre

Hash Que recouvre-t-il ? Égalité attendue avec ?
planned_root_hash SHA-256 canonique du manifest : la liste des N tuples {op_id, type, payload_hash} attendus à l'ouverture. executed_root_hash quand toutes les ops sont décidées
executed_root_hash SHA-256 canonique des op_ids du plan qui ont reçu une décision (signé avec le bon payload OU refusé). Mêmes tuples du plan repris à l'identique → égalité bit-à-bit si plan tenu. planned_root_hash (verdict MATCH)
session_root_hash SHA-256 de TOUTES les entrées d'audit de la session (LOGIN, PLAN_COMMITTED, actions, PLAN_RESOLVED). Empreinte intégrale de ce qui s'est passé. Aucune — sert juste à sceller la session entière dans le bundle.

Ne pas comparer planned_root_hash à session_root_hash — ils n'ont pas la même fonction. La seule égalité qui valide le plan est planned_root_hash == executed_root_hash avec verdict MATCH.

Format du manifest

{
  "planned_operations": [
    {
      "op_id":        "ctr-001",              // identifiant unique au sein du manifest
      "type":         "VALIDATION_ACTION",    // matche l'event_type qui sera enregistré
      "payload_hash": "aaaa...64hex",         // hash exact attendu
      "metadata":     {                        // libre, lisible humain
        "label":      "Avenant CDI Alice Dupont",
        "amount_eur": 45000
      }
    },
    { "op_id": "ctr-002", "type": "VALIDATION_ACTION", "payload_hash": "bbbb...", "metadata": { "label": "Convention mutuelle" } }
  ]
}

Limite : 1000 opérations par manifest. op_id doit matcher [a-zA-Z0-9_:.\-]{1,128} et être unique dans le manifest.

Calcul du planned_root_hash

1. Projeter chaque op sur { op_id, type, payload_hash }     (drop metadata)
2. Trier par op_id ASCII                                    (ordre déterministe)
3. JSON-encode : clés objet dans l'ordre {op_id, type, payload_hash},
   sans espaces, slashes non-échappés, unicode brut
4. sha256(json) → planned_root_hash
        

Ce hash est inséré comme payload_hash de l'entrée SESSION_PLAN_COMMITTED au moment du validate OTP — donc co-signé par organisation + master, à jamais ancré dans la chaîne.

Deux couches : digest plat (v1) + racine Merkle (v2)

Le planned_root_hash ci-dessus est un digest plat : pour le recalculer, un tiers doit recevoir tout le tableau d'opérations. Pratique pour valider le plan entier, inadapté pour en dévoiler une seule ligne.

En parallèle, ProofIt calcule une racine de Merkle RFC 9162 sur les mêmes opérations. Elle permet de prouver qu'une opération précise figurait au plan en O(log n), sans révéler les autres — une preuve d'inclusion.

Encodage de feuille (salé, binaire longueur-préfixé)

Chaque opération est réduite à une feuille canonique. L'encodage binaire longueur-préfixé évite les collisions de concaténation (user1+234 ≡ user+1234) et la non-canonicité de json_encode :

preimage = salt(16o) ‖ u32be(len op_id)       ‖ op_id
                     ‖ u32be(len type)        ‖ type
                     ‖ u32be(len payload_hash) ‖ payload_hash

feuilles triées par strcmp(op_id) croissant   (déterministe cross-SDK)

Le sel de 16 octets (un par op, généré à la création, publié dans PLAN.json) bloque le brute-force du contenu d'une feuille depuis une preuve d'inclusion — op_id et type étant à faible entropie.

Ce qui est scellé à l'OTP

La metadata de l'entrée SESSION_PLAN_COMMITTED co-signe le quadruplet Merkle, jamais la racine nue (règle anti-rejeu) :

{
  "planned_root_hash":     "…",                       // digest plat v1 (co-signé historiquement)
  "planned_manifest_root": "…",                       // racine Merkle v2
  "hash_alg":              "sha256-merkle-rfc9162",
  "tree_size":             15,
  "leaf_format":           "v2"
}

Une MerkleProof se vérifie contre ce quadruplet co-signé, pas contre une racine isolée : c'est ce qui empêche de rejouer une preuve sur un arbre de taille ou d'algo différents. Réf : RFC 9162, RFC 8785, docs/blueprint/04.

À quoi ça sert concrètement

Un parcours en 12 étapes : on veut prouver à un tiers que l'étape ctr-007 figurait bien au plan consenti, sans lui dévoiler les 11 autres (confidentialité). On lui transmet : la feuille ctr-007 + son chemin Merkle (~4 hashes) + le quadruplet co-signé. Il recalcule la racine et vérifie la co-signature. Le digest plat v1, lui, aurait exigé de tout dévoiler.

Cycle de vie

POST /v1/sessions                                          OPEN avec planned_operations
  → server: PlannedManifest::computeRootHash()             planned_root_hash en mémoire
  → response: { session_id, planned_root_hash, ... }

POST /v1/sessions/{id}/validate                            OTP validé
  → LOGIN entry ancrée
  → SESSION_PLAN_COMMITTED entry ancrée (payload_hash = planned_root_hash)
  → mail bénéficiaire d'OUVERTURE envoyé (liste + hash)    preuve prévisionnelle

POST /v1/sessions/{id}/actions   (× N)                     Actions
  → metadata.op_id (OBLIGATOIRE pour le verdict)           sans op_id l'entrée
                                                            n'est jamais comptée
  → payload_hash doit être exact pour MATCH                sinon verdict DIVERGENT

POST /v1/sessions/{id}/close                               Clôture
  → server: walk session entries, projeter sur op_id
  → executed_root_hash = computeExecutedRootHash(...)
  → verdict ∈ {MATCH, PARTIAL, DIVERGENT, EMPTY}
  → SESSION_PLAN_RESOLVED entry ancrée (verdict + hashes)
  → mail bénéficiaire de CLÔTURE envoyé (verdict)          preuve résolutive
        

Verdicts (S59 — refus = action exacte)

Une opération du plan est décidée dès qu'elle reçoit soit une signature soit un refus. « 15 actions prévues à l'ouverture, 15 décisions attendues à la clôture. » Le détail signed vs refused est une métadonnée du verdict, pas un verdict en soi — la conséquence appartient à l'app métier.

MATCH

Toutes les ops du plan ont reçu une décision (signed + refused = total prévu). executed_root_hash === planned_root_hash — égalité cryptographique stricte.

PARTIAL

Sous-ensemble du plan décidé. Au moins une op du plan n'a reçu ni signature ni refus (session finalisée avant traitement complet).

DIVERGENT

op_id décidé hors du plan, OU signed avec payload_hash différent de celui annoncé. Le plan n'a pas été tenu — preuve opposable.

EMPTY

Aucune décision. La session a été ouverte mais rien n'a été fait avant clôture.

Pourquoi un refus compte comme décision : juridiquement, dire NON à un contrat est aussi opposable que dire OUI. Le directeur a tranché. La trace REFUSED dans la chaîne est co-signée Ed25519 par organisation + master, exactement comme une signature. À la clôture, SESSION_PLAN_RESOLVED enregistre signed_count + refused_count séparément en métadonnée pour le reporting, mais le verdict de couverture les considère équivalents.

Comment refuser une opération précise

Deux endpoints distincts, deux sémantiques :

Endpoint Effet Pour le verdict
POST /v1/sessions/{id}/refuse-contract Append REFUSED avec metadata.op_id = contract_id. Session reste OPEN, autres ops toujours possibles. L'op_id est compté comme « décidée par refus » → contribue à MATCH si toutes les autres ops sont aussi décidées.
POST /v1/sessions/{id}/refuse Marque la session ABANDONED, append 1 entrée REFUSED session-wide. Plus aucune action acceptée. Si manifest présent, les ops non encore décidées restent PARTIAL — la session est juste abandonnée prématurément.

Choisir /refuse-contract pour un refus métier ciblé. Choisir /refuse pour un timeout, un OTP épuisé, ou un abandon utilisateur explicite.

Sécurité forensique

Entre SESSION_PLAN_COMMITTED (à l'OPEN) et SESSION_PLAN_RESOLVED (au CLOSE), plusieurs canaries indépendants vérifient la chaîne. Un attaquant qui voudrait altérer le plan rétroactivement devrait :

  1. Ré-écrire l'entrée SESSION_PLAN_COMMITTED — impossible au niveau base (trigger + validation_app)
  2. Refalsifier les deux signatures Ed25519 — nécessite les clés organisation + master
  3. Ré-écrire toutes les entrées suivantes (prev_hash en cascade)
  4. Ré-écrire chaque canary intermédiaire signé entre commit et resolve
  5. Ré-écrire les ancrages OpenTimestamps Bitcoin (impossible — chaîne blockchain irrévocable)
  6. Ré-écrire le mail d'ouverture déjà DKIM-signé dans des boîtes tierces (impossible — clé DKIM compromise = tout perdu)
Bonus : tous les artefacts du manifest (la liste exacte, les hashes, le verdict) sont aussi embarqués dans le dossier ASiC-E dans PLAN.json + PLAN_VERIFICATION.txt. Pour qu'un tiers puisse refaire le calcul à la main en suivant les pas du VERIFICATION.txt.