gradatum-core 0.4.3

Shared primitives: errors, IDs, types
Documentation
//! Frontmatter canonique d'une note Gradatum.
//!
//! ## Design
//!
//! - `Frontmatter` : struct principale sérialisée en YAML dans l'en-tête `.md`.
//! - `ExtraFields` : champs inconnus préservés verbatim (forward-compat B8).
//!   Allocation lazy — `None` si aucun champ extra (évite toute allocation heap si absent).
//! - `tags: SmallVec<[Tag; 4]>` — inline jusqu'à 4 tags sans allocation heap.
//!
//! ## Multi-tenancy
//!
//! `vault_id` est **mandatory** (invariant D10, C4). Pas de note sans tenant.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use std::collections::BTreeMap;

use crate::author::AuthorRef;
use crate::scope::{LocusId, VaultId};
use crate::section::Section;
use crate::status::NoteStatus;
use crate::tag::Tag;

/// Version du schéma frontmatter.
///
/// Incrémentée à chaque breaking change du format. Permet migration forward-compat
/// via `SchemaVersion::CURRENT` + `match schema_version { 1 => ..., 2 => ... }`.
pub type SchemaVersion = u32;

/// Champs inconnus préservés verbatim — allocation lazy.
///
/// Permet aux frontmatters du prédécesseur v1.x de round-tripper sans perte,
/// même si des champs custom non-canoniques sont présents (B8 forward-compat).
///
/// ## Perf
///
/// `Option<Box<BTreeMap<...>>>` évite toute allocation heap pour les notes sans extra.
/// La `Box` réduit la taille de `Frontmatter` dans le cas None (allocation lazy).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ExtraFields(pub Option<Box<BTreeMap<String, toml::Value>>>);

impl ExtraFields {
    /// Retourne `true` si aucun champ extra n'est présent.
    pub fn is_empty(&self) -> bool {
        self.0.as_ref().is_none_or(|m| m.is_empty())
    }

    /// Construit un `ExtraFields` vide sans allocation.
    pub fn empty() -> Self {
        Self(None)
    }

    /// Insère un champ extra (alloue la map si nécessaire).
    ///
    /// # Contrainte JCS (P1-2 audit L0 2026-05-04)
    ///
    /// **`toml::Value::Datetime` est INTERDIT** dans `ExtraFields` si la note
    /// est destinée à être hashée via [`crate::identity::ContentHash::compute`].
    /// Le variant `Datetime` produit une sérialisation JSON non-portable en
    /// `toml 0.8.x` (représentation interne `{"$__toml_private_datetime": ...}`),
    /// ce qui casse la garantie « hash bit-identique cross-language ».
    ///
    /// Pour stocker une date/heure dans `ExtraFields`, utiliser
    /// `toml::Value::String("2026-05-04T10:00:00Z".to_string())` (chaîne ISO 8601
    /// brute) au lieu de `toml::Value::Datetime(...)`.
    ///
    /// Planifié : remplacement de `toml::Value` par `serde_json::Value`
    /// (élimine la contrainte par construction — voir audit L0 recommandations).
    pub fn insert(&mut self, k: String, v: toml::Value) {
        self.0.get_or_insert_with(Default::default).insert(k, v);
    }

    /// Récupère la valeur d'un champ extra.
    pub fn get(&self, k: &str) -> Option<&toml::Value> {
        self.0.as_ref().and_then(|m| m.get(k))
    }
}

/// Frontmatter canonique d'une note Gradatum.
///
/// Sérialisée en YAML dans l'en-tête `---\n...\n---\n` du fichier Markdown.
/// Source de vérité pour le hash `ContentHash` (invariant #1).
///
/// ## Champs optionnels
///
/// Les champs `skip_serializing_if` sont omis en sérialisation si absents,
/// ce qui préserve la lisibilité des frontmatters minimalistes.
///
/// ## Compatibilité
///
/// Les champs YAML inconnus (ex. du prédécesseur v1.x) sont catchés par
/// `#[serde(flatten)]` dans la fixture YAML. Gradatum-markdown gère ce
/// catch au niveau du parser — Frontmatter lui-même expose `extra: ExtraFields`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Frontmatter {
    /// Version du schéma frontmatter. Incrémenté sur breaking change.
    pub schema_version: SchemaVersion,

    /// Tenant mandatory (invariant D10). Alias UI : `vault`.
    pub vault_id: VaultId,

    /// Périmètre ACL sub-vault optionnel. `None` = scope vault root.
    /// `LocusId` finalisé en T03c (scope.rs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locus: Option<LocusId>,

    /// Section canonique de la note.
    pub section: Section,

    /// Statut du cycle de vie.
    pub status: NoteStatus,

    /// Raison optionnelle du statut courant (pour audit trail).
    ///
    /// Ex. : `"rejected by curator: low novelty"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status_reason: Option<String>,

    /// Timestamp du dernier changement de statut.
    ///
    /// Utilisé par les requêtes cron de decay/cleanup.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status_changed: Option<DateTime<Utc>>,

    /// Tags de la note — inline SmallVec jusqu'à 4 tags sans allocation heap.
    ///
    /// Typique < 4 tags — inline `SmallVec` élimine l'allocation heap pour 95% des notes.
    #[serde(default, skip_serializing_if = "SmallVec::is_empty")]
    pub tags: SmallVec<[Tag; 4]>,

    /// Auteur de la note.
    ///
    /// Optionnel en v0.1 pour compat avec les notes du prédécesseur v1.x sans auteur.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub author: Option<AuthorRef>,

    /// Timestamp de création (immutable après premier commit).
    pub created: DateTime<Utc>,

    /// Timestamp de dernière modification.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated: Option<DateTime<Utc>>,

    /// Champs TOML inconnus préservés verbatim (forward-compat B8).
    ///
    /// Allocation lazy : aucune heap allocation si aucun extra.
    /// Omis en sérialisation si vide.
    #[serde(default, skip_serializing_if = "ExtraFields::is_empty")]
    pub extra: ExtraFields,

    /// F-47 — Source de provenance de la note (String, JCS-safe).
    ///
    /// Exemples : `"human-decision"`, `"agent-log"`, `"qa-event"`, `"web-scraped"`.
    ///
    /// ## Invariant ContentHash
    ///
    /// Ce champ est de type `String` (PAS `f32`) pour rester JCS-compatible.
    /// Le score `trust` (float) vit **uniquement** dans la colonne `index.db notes.trust REAL`
    /// (source autoritative scoring F-17) et n'appartient **jamais** à ce struct.
    /// Raison : `serde_jcs` échoue sur `f32::NAN` — tout float dans `Frontmatter` casse
    /// le `ContentHash::compute` (invariant d'unicité du hash, voir `identity.rs`).
    ///
    /// Optionnel + omis si absent en sérialisation (préserve la rétrocompatibilité).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provenance: Option<String>,

    // ── Semantic Forget ──────────────────────────────────────────────────────
    /// Flag de marquage « forgotten » (bool, JCS-safe).
    ///
    /// `true` si la note est marquée forgotten. Absente si `false` (sérialisation
    /// économique — les notes non-forgotten ne portent pas ce champ).
    ///
    /// ## Effet scoring
    ///
    /// `forgotten = true` déclenche un decay exponentiel sur le score de recherche :
    /// `score × 0.5^elapsed_days` (half-life 1 jour, court-circuit AVANT la pénalité
    /// downgraded — jamais de double pénalité, voir `sqlite.rs::search_fts_scored`).
    ///
    /// ## Changement de hash
    ///
    /// Le forget/unforgot modifie ce champ → modifie le hash `sha256_for_history` →
    /// déclenche un snapshot CoW dans `.history/`. C'est intentionnel : un forget est
    /// un événement traçable. Ce champ n'est PAS dans `HISTORY_EXCLUDED_FIELDS`.
    ///
    /// ## Invariant JCS
    ///
    /// Type `bool` (pas `f32`) — JCS-safe, ne casse pas `ContentHash::compute`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub forgotten: Option<bool>,

    /// Timestamp du marquage forgotten (epoch UTC, JCS-safe).
    ///
    /// `None` si la note n'est pas forgotten ou n'a jamais été oubliée.
    /// Utilisé par le calcul decay : `elapsed_days = (now_ms − forgotten_at) / 86_400_000.0`.
    ///
    /// ## Synchronisation index.db
    ///
    /// La colonne `notes.forgotten_at` (INTEGER epoch ms) est la source de vérité pour
    /// le scoring. Cette valeur frontmatter est synchronisée lors du `mark_forgotten`
    /// (C2 via le vault) — pas directement par les méthodes de l'index (C1 scope).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub forgotten_at: Option<DateTime<Utc>>,

    /// Identifiant de l'acteur ayant posé le marquage forgotten.
    ///
    /// Optionnel — `None` si non fourni. Permet l'auditabilité (qui a oublié quoi).
    /// Exemples : `"main-agent"`, `"operator-1"`, `"vault-curator"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub forgotten_by: Option<String>,
}