gradatum_core/identity.rs
1//! Identity immutable : NoteId, ContentHash, NoteVersion, IntegritySignature.
2//!
3//! ## Invariants
4//!
5//! - `NoteId` : ULID, unique, sortable lexicographiquement (timestamp prefix).
6//! Format : `01HQK0ABCDEF0123456789GHIJ` (26 chars Base32).
7//! - `ContentHash` : SHA-256 de `JCS(frontmatter) ++ "\n---\n" ++ body`.
8//! Déterministe cross-langage via JCS RFC 8785. Permet drift detection.
9//! - `NoteVersion` : compteur monotone incrémenté à chaque écriture.
10//! - `IntegritySignature` : optionnel — HMAC-SHA256 ou Ed25519 via `gradatum-acl-auth`.
11//!
12//! ## Pourquoi JCS ?
13//!
14//! `serde_yml::to_string` non-déterministe entre versions de lib.
15//! `serde_json::to_string` non-canonique (ordre clés non garanti).
16//! JCS RFC 8785 = standard IETF : clés ordonnées, floats IEEE 754 canonical,
17//! strings escapées de façon normative. Hash bit-identique Rust/Python/Go/JS.
18//! Décision Q4 brainstorming the maintainer 2026-05-03.
19
20use serde::{Deserialize, Serialize};
21use ulid::Ulid;
22
23use crate::frontmatter::Frontmatter;
24
25/// Clé primaire relationnelle de tout dans Gradatum.
26///
27/// ULID (Universally Unique Lexicographically Sortable Identifier) :
28/// - 128 bits : 48 bits timestamp ms + 80 bits random.
29/// - Sortable lexicographiquement → les notes plus récentes sont "après" les anciennes.
30/// - Monotone dans le même milliseconde (pas de collision dans un process).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(transparent)]
33pub struct NoteId(pub Ulid);
34
35impl NoteId {
36 /// Génère un nouveau NoteId unique.
37 pub fn new() -> Self {
38 Self(Ulid::new())
39 }
40
41 /// Retourne le timestamp ULID en millisecondes Unix.
42 ///
43 /// Utile pour trier les notes par ordre d'insertion sans accéder au frontmatter.
44 pub fn timestamp_ms(&self) -> u64 {
45 self.0.timestamp_ms()
46 }
47}
48
49impl Default for NoteId {
50 fn default() -> Self {
51 Self::new()
52 }
53}
54
55impl std::fmt::Display for NoteId {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 write!(f, "{}", self.0)
58 }
59}
60
61/// Hash SHA-256 du contenu canonique d'une note.
62///
63/// **Canonicalisation = JCS RFC 8785** (`serde_jcs`) :
64/// ```text
65/// input = JCS(frontmatter_as_json) ++ b"\n---\n" ++ body_utf8
66/// hash = SHA-256(input)
67/// ```
68///
69/// Le hash est **indépendant du format on-disk** (YAML vs TOML pour le frontmatter) :
70/// le frontmatter est re-sérialisé en JSON canonique avant le hash → reproductible
71/// cross-langage (Python, Go, JavaScript, Rust) avec la même lib JCS.
72///
73/// ## Utilisation
74///
75/// - Drift detection : compare `ContentHash::compute(reparse(md))` vs `notes.content_hash` SQLite.
76/// - Cache key dans `gradatum-cache` : `(vault_id, content_hash)`.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct ContentHash(pub [u8; 32]);
80
81impl ContentHash {
82 /// Calcule le hash depuis un frontmatter et un body.
83 ///
84 /// # Panics
85 ///
86 /// Ne panique jamais en production. `serde_jcs::to_string` retourne une erreur
87 /// uniquement si `Frontmatter` contient des types non-sérialisables en JSON (ex. `f32::NAN`).
88 /// `Frontmatter` ne contient aucun float → `expect` est justifié ici.
89 pub fn compute(frontmatter: &Frontmatter, body: &str) -> Self {
90 use sha2::{Digest, Sha256};
91
92 // JCS RFC 8785 : clés ordonnées, floats IEEE 754, strings normatives.
93 // Garantit un hash bit-identique quel que soit le producer ou la lib YAML.
94 let canonical = serde_jcs::to_string(frontmatter).expect(
95 "Frontmatter est toujours sérialisable en JCS (pas de f32::NAN ni de types non-JSON)",
96 );
97
98 let mut hasher = Sha256::new();
99 hasher.update(canonical.as_bytes());
100 hasher.update(b"\n---\n");
101 hasher.update(body.as_bytes());
102
103 ContentHash(hasher.finalize().into())
104 }
105
106 /// Retourne la représentation hexadécimale du hash (64 chars lowercase).
107 pub fn hex(&self) -> String {
108 self.0.iter().map(|b| format!("{b:02x}")).collect()
109 }
110}
111
112impl std::fmt::Display for ContentHash {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.write_str(&self.hex())
115 }
116}
117
118/// Compteur de version monotone par note.
119///
120/// Incrémenté à chaque écriture par `gradatum-worker`. Le couple `(NoteId, NoteVersion)`
121/// est unique dans le store (invariant d'unicité).
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
123#[serde(transparent)]
124pub struct NoteVersion(pub u32);
125
126impl NoteVersion {
127 /// Version initiale à la création d'une note.
128 pub fn initial() -> Self {
129 Self(1)
130 }
131
132 /// Retourne la version suivante (version courante + 1).
133 pub fn next(&self) -> Self {
134 Self(self.0 + 1)
135 }
136}
137
138/// Signature cryptographique optionnelle.
139///
140/// Absente par défaut : la détection de drift via `ContentHash` suffit pour les usages courants.
141/// HMAC-SHA256 ou Ed25519 vault-scoped disponible via `gradatum-acl-auth` avec la couche bearer auth.
142///
143/// Sépare drift accidentel (`ContentHash`) de tamper malveillant (`IntegritySignature`)
144/// — décision Q5/B13.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(transparent)]
147pub struct IntegritySignature(pub Vec<u8>);