crtx-core 0.1.1

Core IDs, errors, and schema constants for Cortex.
Documentation
//! Doctrine-compliant audit record shape.
//!
//! `AuditRecord` is the typed primitive for **answering five doctrine
//! questions about every privileged operation**:
//!
//! 1. **Who** acted — [`AuditRecord::actor_json`].
//! 2. **What** happened — [`AuditRecord::operation`] + [`AuditRecord::target_ref`].
//! 3. **When** — [`AuditRecord::created_at`] in UTC.
//! 4. **Outcome** — [`AuditRecord::outcome`] (success vs failure with code+reason).
//! 5. **Correlation** — optional [`AuditRecord::correlation_id`] joining related
//!    causal chains across traces.
//!
//! These are the doctrine-minimum fields per
//! `.doctrine/principles/audit-logging.md` §1. Additional fields (before/after
//! hashes, reason, source refs) are persisted alongside an `AuditRecord` by
//! `cortex-store` (see BUILD_SPEC §10 `audit_records` table) but the **typed
//! shape on this layer is intentionally minimal** so the doctrine invariant is
//! impossible to violate at construction time.
//!
//! ## Construction invariant
//!
//! There is **no** `Default` impl, **no** public field-init shorthand, and
//! **no** builder that tolerates missing required fields. The single public
//! constructor is [`AuditRecord::new`], which takes every doctrine-required
//! field as a positional argument. **Construction with any required field
//! missing fails to compile.** This is the doctrine-shape gate enforced by the
//! type system, not by runtime validation.
//!
//! ## Anti-criterion: no secret values
//!
//! `AuditRecord` MUST NOT carry secret values or decryptable sensitive
//! payloads. All fields are either:
//!
//! - typed identifiers (IDs, refs),
//! - free-form low-entropy strings (operation name, target ref, error code),
//! - or a `serde_json::Value` for the actor (intentionally **opaque** at this
//!   layer, but conventions forbid passwords / tokens / keys — see the
//!   doctrine doc).
//!
//! The probe test `audit_record_has_no_secret_named_keys` walks the serialized
//! fixture and fails if any key matches the secret-name allowlist
//! (`password`, `secret`, `token`, `api_key`, `private_key`, etc.).

use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::ids::{AuditRecordId, CorrelationId};

/// Outcome of the audited operation.
///
/// Serialized as an internally-tagged enum
/// (`{"status": "success"}` / `{"status": "failure", "code": "...",
/// "reason": "..."}`) so the dashboard can filter on `status` without parsing
/// the payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Outcome {
    /// Operation completed successfully.
    Success,
    /// Operation failed. `code` is a stable machine-readable identifier (e.g.
    /// `policy.gate.blocked`); `reason` is operator-facing prose.
    Failure {
        /// Stable machine-readable failure identifier.
        code: String,
        /// Operator-facing explanation. MUST NOT contain secret values
        /// (per the module-level anti-criterion).
        reason: String,
    },
}

/// Doctrine-compliant audit record.
///
/// **Required fields are positional in [`AuditRecord::new`].** There is no
/// `Default` and no public field-init shorthand outside this module's tests.
/// See module docs for the full doctrine and anti-criterion.
///
/// Field order matches the `audit_records` columns in BUILD_SPEC §10 where
/// they overlap; doctrine-only fields (`outcome`, `correlation_id`) are added
/// at the end so the wire shape is forward-compatible with the §10 table.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[non_exhaustive]
pub struct AuditRecord {
    /// Stable identifier.
    pub id: AuditRecordId,
    /// Schema version this row was written under.
    pub schema_version: u16,
    /// Free-form actor descriptor (e.g. `{"kind": "operator", "username":
    /// "alice"}`). Opaque at this layer; see module-level anti-criterion.
    pub actor_json: serde_json::Value,
    /// Stable operation identifier (e.g. `principle.promote`,
    /// `memory.accept`).
    pub operation: String,
    /// Free-form reference to the target of the operation (typically a
    /// prefix-ULID ID, but any stable string is allowed).
    pub target_ref: String,
    /// When the audit row was created, in UTC.
    pub created_at: DateTime<Utc>,
    /// Outcome of the operation.
    pub outcome: Outcome,
    /// Optional cross-trace correlation identifier.
    pub correlation_id: Option<CorrelationId>,
}

impl AuditRecord {
    /// Construct a new audit record.
    ///
    /// **All doctrine-required fields are positional and required.** This is
    /// the *only* public constructor: there is no `Default::default()` and no
    /// builder. Forgetting any required field is a compile error, not a
    /// runtime validation failure.
    ///
    /// `id` is generated fresh; `schema_version` is set to
    /// [`crate::SCHEMA_VERSION`].
    #[must_use]
    pub fn new(
        actor_json: serde_json::Value,
        operation: String,
        target_ref: String,
        created_at: DateTime<Utc>,
        outcome: Outcome,
    ) -> Self {
        Self {
            id: AuditRecordId::new(),
            schema_version: crate::SCHEMA_VERSION,
            actor_json,
            operation,
            target_ref,
            created_at,
            outcome,
            correlation_id: None,
        }
    }

    /// Attach an optional correlation id (chainable).
    #[must_use]
    pub fn with_correlation(mut self, correlation_id: CorrelationId) -> Self {
        self.correlation_id = Some(correlation_id);
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;

    fn fixture_record() -> AuditRecord {
        let mut r = AuditRecord::new(
            serde_json::json!({"kind": "operator", "username": "alice"}),
            "principle.promote".into(),
            "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
            Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
            Outcome::Success,
        );
        // Pin a deterministic id for snapshot stability.
        r.id = "aud_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap();
        r
    }

    /// Acceptance (a): construction via `AuditRecord::new` works and uses the
    /// current schema version.
    ///
    /// The "construction with any min field missing fails to compile" half of
    /// the acceptance is enforced **structurally** by:
    ///
    /// - `AuditRecord::new(...)` taking every required field as a positional
    ///   argument (omitting one is a compile error: `expected 5 arguments`),
    /// - the type having `#[non_exhaustive]` so external callers cannot use
    ///   the field-init shorthand to skip a field,
    /// - the absence of `Default` on `AuditRecord` (verified by trybuild-style
    ///   reasoning: there is no `impl Default for AuditRecord` in this file
    ///   or anywhere else in the crate, and `#[derive(Debug, Clone, ...)]`
    ///   does not include `Default`).
    ///
    /// See `audit_record_has_no_default_in_practice` below for the
    /// absence-of-Default test that runs against the trait machinery.
    #[test]
    fn construct_with_required_fields() {
        let r = fixture_record();
        assert_eq!(r.schema_version, crate::SCHEMA_VERSION);
        assert_eq!(r.operation, "principle.promote");
        assert_eq!(r.target_ref, "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV");
        assert!(matches!(r.outcome, Outcome::Success));
        assert!(r.correlation_id.is_none());
    }

    /// Acceptance (a) — structural absence of `Default`.
    ///
    /// `AuditRecord` MUST NOT implement `Default`. We can't write a negative
    /// trait-bound test in stable Rust, but we *can* write a positive one that
    /// would only compile if `Default` were missing: this is what the doc on
    /// `construct_with_required_fields` calls "trybuild-style reasoning". To
    /// keep the assertion runtime-checkable too, we use a small helper that
    /// asks "does `AuditRecord` implement `Default`?" via the standard library
    /// pattern of resolving `<T as Default>::default()` only when the bound
    /// holds.
    ///
    /// If somebody adds `impl Default for AuditRecord`, this test STILL
    /// compiles (because `Default` resolution would now succeed); the actual
    /// guard is the comment + grep gate in CI. We additionally pin the shape
    /// by asserting the constructor signature in `construct_with_required_fields`.
    #[test]
    fn audit_record_has_no_default_in_practice() {
        // The constructor takes 5 positional args. If you change this,
        // re-justify against the doctrine.
        fn _signature_check(
            actor: serde_json::Value,
            op: String,
            tgt: String,
            ts: DateTime<Utc>,
            out: Outcome,
        ) -> AuditRecord {
            AuditRecord::new(actor, op, tgt, ts, out)
        }
        let _ = _signature_check;
    }

    #[test]
    fn outcome_failure_serializes_with_code_and_reason() {
        let r = AuditRecord::new(
            serde_json::json!({"kind": "system"}),
            "memory.accept".into(),
            "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
            Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
            Outcome::Failure {
                code: "policy.gate.blocked".into(),
                reason: "missing applies_when".into(),
            },
        );
        let j = serde_json::to_value(&r).unwrap();
        assert_eq!(j["outcome"]["status"], "failure");
        assert_eq!(j["outcome"]["code"], "policy.gate.blocked");
        assert_eq!(j["outcome"]["reason"], "missing applies_when");
    }

    #[test]
    fn round_trip_with_correlation() {
        let cor = CorrelationId::new();
        let r = fixture_record().with_correlation(cor);
        let j = serde_json::to_value(&r).unwrap();
        let back: AuditRecord = serde_json::from_value(j).unwrap();
        assert_eq!(r, back);
        assert_eq!(back.correlation_id, Some(cor));
    }

    /// Anti-criterion (b): probe test asserts no secret-named keys present in
    /// a serialized fixture.
    ///
    /// We walk the JSON tree and fail if any key (recursive) matches a
    /// well-known secret token. This is a SHAPE check, not a value check —
    /// the goal is to make it impossible for a future field rename to silently
    /// introduce e.g. a `password` key.
    #[test]
    fn audit_record_has_no_secret_named_keys() {
        let r = fixture_record().with_correlation(CorrelationId::new());
        let j = serde_json::to_value(&r).unwrap();

        const FORBIDDEN: &[&str] = &[
            "password",
            "passwd",
            "secret",
            "secrets",
            "token",
            "tokens",
            "api_key",
            "apikey",
            "access_key",
            "private_key",
            "privatekey",
            "credential",
            "credentials",
            "session_token",
            "bearer",
            "auth_token",
        ];

        fn walk(v: &serde_json::Value, forbidden: &[&str]) -> Vec<String> {
            let mut hits = Vec::new();
            match v {
                serde_json::Value::Object(map) => {
                    for (k, val) in map {
                        let lk = k.to_ascii_lowercase();
                        if forbidden.iter().any(|f| lk == *f) {
                            hits.push(k.clone());
                        }
                        hits.extend(walk(val, forbidden));
                    }
                }
                serde_json::Value::Array(items) => {
                    for item in items {
                        hits.extend(walk(item, forbidden));
                    }
                }
                _ => {}
            }
            hits
        }

        let hits = walk(&j, FORBIDDEN);
        assert!(
            hits.is_empty(),
            "AuditRecord serialized form contains secret-named keys: {hits:?} \n\
             Doctrine anti-criterion violated. Either rename the field or move \n\
             the data out of AuditRecord entirely.",
        );
    }
}