cortex_core/audit.rs
1//! Doctrine-compliant audit record shape.
2//!
3//! `AuditRecord` is the typed primitive for **answering five doctrine
4//! questions about every privileged operation**:
5//!
6//! 1. **Who** acted — [`AuditRecord::actor_json`].
7//! 2. **What** happened — [`AuditRecord::operation`] + [`AuditRecord::target_ref`].
8//! 3. **When** — [`AuditRecord::created_at`] in UTC.
9//! 4. **Outcome** — [`AuditRecord::outcome`] (success vs failure with code+reason).
10//! 5. **Correlation** — optional [`AuditRecord::correlation_id`] joining related
11//! causal chains across traces.
12//!
13//! These are the doctrine-minimum fields per
14//! `.doctrine/principles/audit-logging.md` §1. Additional fields (before/after
15//! hashes, reason, source refs) are persisted alongside an `AuditRecord` by
16//! `cortex-store` (see BUILD_SPEC §10 `audit_records` table) but the **typed
17//! shape on this layer is intentionally minimal** so the doctrine invariant is
18//! impossible to violate at construction time.
19//!
20//! ## Construction invariant
21//!
22//! There is **no** `Default` impl, **no** public field-init shorthand, and
23//! **no** builder that tolerates missing required fields. The single public
24//! constructor is [`AuditRecord::new`], which takes every doctrine-required
25//! field as a positional argument. **Construction with any required field
26//! missing fails to compile.** This is the doctrine-shape gate enforced by the
27//! type system, not by runtime validation.
28//!
29//! ## Anti-criterion: no secret values
30//!
31//! `AuditRecord` MUST NOT carry secret values or decryptable sensitive
32//! payloads. All fields are either:
33//!
34//! - typed identifiers (IDs, refs),
35//! - free-form low-entropy strings (operation name, target ref, error code),
36//! - or a `serde_json::Value` for the actor (intentionally **opaque** at this
37//! layer, but conventions forbid passwords / tokens / keys — see the
38//! doctrine doc).
39//!
40//! The probe test `audit_record_has_no_secret_named_keys` walks the serialized
41//! fixture and fails if any key matches the secret-name allowlist
42//! (`password`, `secret`, `token`, `api_key`, `private_key`, etc.).
43
44use chrono::{DateTime, Utc};
45use schemars::JsonSchema;
46use serde::{Deserialize, Serialize};
47
48use crate::ids::{AuditRecordId, CorrelationId};
49
50/// Outcome of the audited operation.
51///
52/// Serialized as an internally-tagged enum
53/// (`{"status": "success"}` / `{"status": "failure", "code": "...",
54/// "reason": "..."}`) so the dashboard can filter on `status` without parsing
55/// the payload.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57#[serde(tag = "status", rename_all = "snake_case")]
58pub enum Outcome {
59 /// Operation completed successfully.
60 Success,
61 /// Operation failed. `code` is a stable machine-readable identifier (e.g.
62 /// `policy.gate.blocked`); `reason` is operator-facing prose.
63 Failure {
64 /// Stable machine-readable failure identifier.
65 code: String,
66 /// Operator-facing explanation. MUST NOT contain secret values
67 /// (per the module-level anti-criterion).
68 reason: String,
69 },
70}
71
72/// Doctrine-compliant audit record.
73///
74/// **Required fields are positional in [`AuditRecord::new`].** There is no
75/// `Default` and no public field-init shorthand outside this module's tests.
76/// See module docs for the full doctrine and anti-criterion.
77///
78/// Field order matches the `audit_records` columns in BUILD_SPEC §10 where
79/// they overlap; doctrine-only fields (`outcome`, `correlation_id`) are added
80/// at the end so the wire shape is forward-compatible with the §10 table.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82#[non_exhaustive]
83pub struct AuditRecord {
84 /// Stable identifier.
85 pub id: AuditRecordId,
86 /// Schema version this row was written under.
87 pub schema_version: u16,
88 /// Free-form actor descriptor (e.g. `{"kind": "operator", "username":
89 /// "alice"}`). Opaque at this layer; see module-level anti-criterion.
90 pub actor_json: serde_json::Value,
91 /// Stable operation identifier (e.g. `principle.promote`,
92 /// `memory.accept`).
93 pub operation: String,
94 /// Free-form reference to the target of the operation (typically a
95 /// prefix-ULID ID, but any stable string is allowed).
96 pub target_ref: String,
97 /// When the audit row was created, in UTC.
98 pub created_at: DateTime<Utc>,
99 /// Outcome of the operation.
100 pub outcome: Outcome,
101 /// Optional cross-trace correlation identifier.
102 pub correlation_id: Option<CorrelationId>,
103}
104
105impl AuditRecord {
106 /// Construct a new audit record.
107 ///
108 /// **All doctrine-required fields are positional and required.** This is
109 /// the *only* public constructor: there is no `Default::default()` and no
110 /// builder. Forgetting any required field is a compile error, not a
111 /// runtime validation failure.
112 ///
113 /// `id` is generated fresh; `schema_version` is set to
114 /// [`crate::SCHEMA_VERSION`].
115 #[must_use]
116 pub fn new(
117 actor_json: serde_json::Value,
118 operation: String,
119 target_ref: String,
120 created_at: DateTime<Utc>,
121 outcome: Outcome,
122 ) -> Self {
123 Self {
124 id: AuditRecordId::new(),
125 schema_version: crate::SCHEMA_VERSION,
126 actor_json,
127 operation,
128 target_ref,
129 created_at,
130 outcome,
131 correlation_id: None,
132 }
133 }
134
135 /// Attach an optional correlation id (chainable).
136 #[must_use]
137 pub fn with_correlation(mut self, correlation_id: CorrelationId) -> Self {
138 self.correlation_id = Some(correlation_id);
139 self
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use chrono::TimeZone;
147
148 fn fixture_record() -> AuditRecord {
149 let mut r = AuditRecord::new(
150 serde_json::json!({"kind": "operator", "username": "alice"}),
151 "principle.promote".into(),
152 "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
153 Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
154 Outcome::Success,
155 );
156 // Pin a deterministic id for snapshot stability.
157 r.id = "aud_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap();
158 r
159 }
160
161 /// Acceptance (a): construction via `AuditRecord::new` works and uses the
162 /// current schema version.
163 ///
164 /// The "construction with any min field missing fails to compile" half of
165 /// the acceptance is enforced **structurally** by:
166 ///
167 /// - `AuditRecord::new(...)` taking every required field as a positional
168 /// argument (omitting one is a compile error: `expected 5 arguments`),
169 /// - the type having `#[non_exhaustive]` so external callers cannot use
170 /// the field-init shorthand to skip a field,
171 /// - the absence of `Default` on `AuditRecord` (verified by trybuild-style
172 /// reasoning: there is no `impl Default for AuditRecord` in this file
173 /// or anywhere else in the crate, and `#[derive(Debug, Clone, ...)]`
174 /// does not include `Default`).
175 ///
176 /// See `audit_record_has_no_default_in_practice` below for the
177 /// absence-of-Default test that runs against the trait machinery.
178 #[test]
179 fn construct_with_required_fields() {
180 let r = fixture_record();
181 assert_eq!(r.schema_version, crate::SCHEMA_VERSION);
182 assert_eq!(r.operation, "principle.promote");
183 assert_eq!(r.target_ref, "prn_01ARZ3NDEKTSV4RRFFQ69G5FAV");
184 assert!(matches!(r.outcome, Outcome::Success));
185 assert!(r.correlation_id.is_none());
186 }
187
188 /// Acceptance (a) — structural absence of `Default`.
189 ///
190 /// `AuditRecord` MUST NOT implement `Default`. We can't write a negative
191 /// trait-bound test in stable Rust, but we *can* write a positive one that
192 /// would only compile if `Default` were missing: this is what the doc on
193 /// `construct_with_required_fields` calls "trybuild-style reasoning". To
194 /// keep the assertion runtime-checkable too, we use a small helper that
195 /// asks "does `AuditRecord` implement `Default`?" via the standard library
196 /// pattern of resolving `<T as Default>::default()` only when the bound
197 /// holds.
198 ///
199 /// If somebody adds `impl Default for AuditRecord`, this test STILL
200 /// compiles (because `Default` resolution would now succeed); the actual
201 /// guard is the comment + grep gate in CI. We additionally pin the shape
202 /// by asserting the constructor signature in `construct_with_required_fields`.
203 #[test]
204 fn audit_record_has_no_default_in_practice() {
205 // The constructor takes 5 positional args. If you change this,
206 // re-justify against the doctrine.
207 fn _signature_check(
208 actor: serde_json::Value,
209 op: String,
210 tgt: String,
211 ts: DateTime<Utc>,
212 out: Outcome,
213 ) -> AuditRecord {
214 AuditRecord::new(actor, op, tgt, ts, out)
215 }
216 let _ = _signature_check;
217 }
218
219 #[test]
220 fn outcome_failure_serializes_with_code_and_reason() {
221 let r = AuditRecord::new(
222 serde_json::json!({"kind": "system"}),
223 "memory.accept".into(),
224 "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
225 Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
226 Outcome::Failure {
227 code: "policy.gate.blocked".into(),
228 reason: "missing applies_when".into(),
229 },
230 );
231 let j = serde_json::to_value(&r).unwrap();
232 assert_eq!(j["outcome"]["status"], "failure");
233 assert_eq!(j["outcome"]["code"], "policy.gate.blocked");
234 assert_eq!(j["outcome"]["reason"], "missing applies_when");
235 }
236
237 #[test]
238 fn round_trip_with_correlation() {
239 let cor = CorrelationId::new();
240 let r = fixture_record().with_correlation(cor);
241 let j = serde_json::to_value(&r).unwrap();
242 let back: AuditRecord = serde_json::from_value(j).unwrap();
243 assert_eq!(r, back);
244 assert_eq!(back.correlation_id, Some(cor));
245 }
246
247 /// Anti-criterion (b): probe test asserts no secret-named keys present in
248 /// a serialized fixture.
249 ///
250 /// We walk the JSON tree and fail if any key (recursive) matches a
251 /// well-known secret token. This is a SHAPE check, not a value check —
252 /// the goal is to make it impossible for a future field rename to silently
253 /// introduce e.g. a `password` key.
254 #[test]
255 fn audit_record_has_no_secret_named_keys() {
256 let r = fixture_record().with_correlation(CorrelationId::new());
257 let j = serde_json::to_value(&r).unwrap();
258
259 const FORBIDDEN: &[&str] = &[
260 "password",
261 "passwd",
262 "secret",
263 "secrets",
264 "token",
265 "tokens",
266 "api_key",
267 "apikey",
268 "access_key",
269 "private_key",
270 "privatekey",
271 "credential",
272 "credentials",
273 "session_token",
274 "bearer",
275 "auth_token",
276 ];
277
278 fn walk(v: &serde_json::Value, forbidden: &[&str]) -> Vec<String> {
279 let mut hits = Vec::new();
280 match v {
281 serde_json::Value::Object(map) => {
282 for (k, val) in map {
283 let lk = k.to_ascii_lowercase();
284 if forbidden.iter().any(|f| lk == *f) {
285 hits.push(k.clone());
286 }
287 hits.extend(walk(val, forbidden));
288 }
289 }
290 serde_json::Value::Array(items) => {
291 for item in items {
292 hits.extend(walk(item, forbidden));
293 }
294 }
295 _ => {}
296 }
297 hits
298 }
299
300 let hits = walk(&j, FORBIDDEN);
301 assert!(
302 hits.is_empty(),
303 "AuditRecord serialized form contains secret-named keys: {hits:?} \n\
304 Doctrine anti-criterion violated. Either rename the field or move \n\
305 the data out of AuditRecord entirely.",
306 );
307 }
308}