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 :
- Un mail d'ouverture avec la liste lisible des opérations +
planned_root_hash - Un mail de clôture avec le verdict +
planned_root_hashetexecuted_root_hashcô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.
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.
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).
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.
Aucune décision. La session a été ouverte mais rien n'a été fait avant clôture.
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 :
- Ré-écrire l'entrée
SESSION_PLAN_COMMITTED— impossible au niveau base (trigger + validation_app) - Refalsifier les deux signatures Ed25519 — nécessite les clés organisation + master
- Ré-écrire toutes les entrées suivantes (prev_hash en cascade)
- Ré-écrire chaque canary intermédiaire signé entre commit et resolve
- Ré-écrire les ancrages OpenTimestamps Bitcoin (impossible — chaîne blockchain irrévocable)
- Ré-écrire le mail d'ouverture déjà DKIM-signé dans des boîtes tierces (impossible — clé DKIM compromise = tout perdu)
PLAN.json + PLAN_VERIFICATION.txt. Pour qu'un tiers puisse refaire le calcul à la main en suivant les pas du VERIFICATION.txt.