Skip to main content

reddb_server/runtime/ai/
audit_record_builder.rs

1//! `AuditRecordBuilder` — pure builder for `red_ask_audit` rows.
2//!
3//! Issue #402 (PRD #391): every ASK call writes one row to the
4//! `red_ask_audit` system collection. This module owns the *shape* of
5//! that row: which fields exist, how `answer_hash` is computed, what
6//! `include_answer` toggles, what the keys look like on the wire.
7//!
8//! Deep module: no I/O, no clock, no collection access. Inputs are
9//! plain data (the call state assembled by `execute_ask`); the output
10//! is a [`BTreeMap`] ready to be passed to the insert path. Keeping
11//! the builder pure means the audit schema is pinned by unit tests
12//! and can't drift behind the spec.
13//!
14//! ## Field policy
15//!
16//! Always present (PRD §audit, ADR-319 `red_*` convention):
17//!
18//! - `ts` — caller-injected wall time in epoch nanoseconds. The
19//!   builder takes it as input rather than reading the clock so that
20//!   tests are deterministic; production callers feed
21//!   `SystemTime::now()`.
22//! - `tenant`, `user`, `role` — identity columns. Empty strings are
23//!   allowed (e.g. embedded usage with no auth context) so the audit
24//!   row still lands.
25//! - `question` — verbatim user question.
26//! - `sources_urns` — JSON array of stable source URNs (post-fusion,
27//!   pre-redaction). Order is preserved from the input.
28//! - `provider`, `model` — provider token and model id sent to the
29//!   LLM. Both are recorded as the caller actually used them, not
30//!   what the user requested, so the determinism contract (#400) and
31//!   the capability fallback (#396) are auditable.
32//! - `prompt_tokens`, `completion_tokens` — usage counters reported
33//!   by the provider. Stored as `i64` (some providers can return 0).
34//! - `cost_usd` — cost accrued for this call, `f64`. Always recorded
35//!   even on cache hits (zero in that case).
36//! - `answer_hash` — lowercase hex SHA-256 of the answer string.
37//!   Deterministic; recorded regardless of `include_answer`.
38//! - `citations` — JSON array of 1-indexed citation markers found in
39//!   the answer (from `CitationParser`).
40//! - `cache_hit` — boolean; `true` when answered from the answer
41//!   cache (#403) without calling the LLM.
42//! - `mode` — `"strict"` or `"lenient"`, the mode *effectively* used
43//!   after any provider-capability fallback (#396).
44//! - `temperature`, `seed` — determinism knobs actually sent to the
45//!   provider. `null` means the selected provider does not support that
46//!   knob, not that a default was forgotten.
47//! - `validation_ok` — boolean; `true` iff the citation validator
48//!   (#395) returned `Decision::Ok` for the final answer.
49//! - `retry_count` — `0` or `1`. The strict-mode retry budget is
50//!   pinned at one (#395), but we record the count so a future budget
51//!   change doesn't silently corrupt the schema.
52//! - `errors` — JSON array of `{kind, detail}` objects. Empty when
53//!   the call succeeded.
54//!
55//! Conditional:
56//!
57//! - `answer` — full answer string. Only present when
58//!   `Settings::include_answer == true`. Default is `false`; the
59//!   `ask.audit.include_answer` setting flips it on per deployment.
60//!   The shape change is explicit (key absent vs key present) so
61//!   downstream consumers can detect it without sentinels.
62//!
63//! ## Why a deep module
64//!
65//! The schema is a contract: operators write dashboards against it,
66//! retention purges read `ts` directly, replication forwarders
67//! serialize it. Concentrating the build logic in one place — with
68//! every field pinned by a test — keeps the contract honest. The
69//! `execute_ask` glue can then call `AuditRecordBuilder::build(...)`
70//! and treat the result as opaque key/value rows.
71
72use std::collections::BTreeMap;
73
74use sha2::{Digest, Sha256};
75
76use crate::json;
77use crate::runtime::ai::strict_validator::{Mode, ValidationError, ValidationErrorKind};
78use crate::serde_json::Value;
79
80/// Deployment-level audit settings.
81///
82/// Surfaced via the `ask.audit.*` settings tree; threaded into the
83/// builder so tests can pin both shapes without touching global
84/// config.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub struct Settings {
87    /// When `true`, store the full answer string under the `answer`
88    /// key in addition to `answer_hash`. Default `false`.
89    pub include_answer: bool,
90}
91
92/// All call state needed to render one audit row. Plain data —
93/// borrowed where cheap, owned only for the fields the caller almost
94/// always owns at insert time.
95#[derive(Debug, Clone)]
96pub struct CallState<'a> {
97    /// Epoch nanoseconds. Injected by the caller (do not read the
98    /// clock inside the builder).
99    pub ts_nanos: i64,
100    pub tenant: &'a str,
101    pub user: &'a str,
102    pub role: &'a str,
103    pub question: &'a str,
104    pub sources_urns: &'a [String],
105    pub provider: &'a str,
106    pub model: &'a str,
107    pub prompt_tokens: i64,
108    pub completion_tokens: i64,
109    pub cost_usd: f64,
110    /// The full LLM answer text. Always hashed; only stored when
111    /// `Settings::include_answer == true`.
112    pub answer: &'a str,
113    /// 1-indexed citation markers parsed from the answer.
114    pub citations: &'a [u32],
115    pub cache_hit: bool,
116    /// The mode effectively used — after provider capability fallback
117    /// (#396), not the mode the user asked for.
118    pub effective_mode: Mode,
119    pub temperature: Option<f32>,
120    pub seed: Option<u64>,
121    pub validation_ok: bool,
122    pub retry_count: u32,
123    pub errors: &'a [ValidationError],
124    /// Planner-first fields (ADR 0068 / #1747). `None` on the RAG path so
125    /// the row shape is unchanged; `Some` on the planner path, where the
126    /// audit row grows the routed intent, plan summary, and executed query.
127    pub intent: Option<&'a str>,
128    pub plan_summary: Option<&'a str>,
129    pub executed_query: Option<&'a str>,
130}
131
132/// Produce one audit row, ready to insert into `red_ask_audit`.
133///
134/// The keys are stable (pinned by tests) and the value types match
135/// what the storage layer expects for the collection's columns. The
136/// `BTreeMap` ordering is alphabetical; downstream consumers MUST NOT
137/// rely on insertion order, but operators reading raw rows benefit
138/// from the predictable layout.
139pub fn build(state: &CallState<'_>, settings: Settings) -> BTreeMap<&'static str, Value> {
140    let mut row: BTreeMap<&'static str, Value> = BTreeMap::new();
141
142    row.insert("ts", json!(state.ts_nanos));
143    row.insert("tenant", json!(state.tenant));
144    row.insert("user", json!(state.user));
145    row.insert("role", json!(state.role));
146    row.insert("question", json!(state.question));
147    row.insert("sources_urns", json!(state.sources_urns));
148    row.insert("provider", json!(state.provider));
149    row.insert("model", json!(state.model));
150    row.insert("prompt_tokens", json!(state.prompt_tokens));
151    row.insert("completion_tokens", json!(state.completion_tokens));
152    row.insert("cost_usd", json!(state.cost_usd));
153    row.insert("answer_hash", json!(answer_hash(state.answer)));
154    row.insert("citations", json!(state.citations));
155    row.insert("cache_hit", json!(state.cache_hit));
156    row.insert("mode", json!(mode_str(state.effective_mode)));
157    row.insert(
158        "temperature",
159        state
160            .temperature
161            .map(|value| json!(value))
162            .unwrap_or(Value::Null),
163    );
164    row.insert(
165        "seed",
166        state.seed.map(|value| json!(value)).unwrap_or(Value::Null),
167    );
168    row.insert("validation_ok", json!(state.validation_ok));
169    row.insert("retry_count", json!(state.retry_count));
170    row.insert(
171        "errors",
172        Value::Array(state.errors.iter().map(error_json).collect()),
173    );
174
175    if settings.include_answer {
176        row.insert("answer", json!(state.answer));
177    }
178
179    // Planner-first fields (#1747): present only on the planner path.
180    if let Some(intent) = state.intent {
181        row.insert("intent", json!(intent));
182    }
183    if let Some(plan_summary) = state.plan_summary {
184        row.insert("plan_summary", json!(plan_summary));
185    }
186    if let Some(executed_query) = state.executed_query {
187        row.insert("executed_query", json!(executed_query));
188    }
189
190    row
191}
192
193/// Lowercase hex SHA-256 of the answer text. Deterministic; the
194/// audit row is identical for byte-equal answers regardless of when
195/// or where the call ran.
196pub fn answer_hash(answer: &str) -> String {
197    let mut hasher = Sha256::new();
198    hasher.update(answer.as_bytes());
199    let bytes = hasher.finalize();
200    let mut out = String::with_capacity(bytes.len() * 2);
201    for b in bytes {
202        out.push_str(&format!("{b:02x}"));
203    }
204    out
205}
206
207fn mode_str(mode: Mode) -> &'static str {
208    match mode {
209        Mode::Strict => "strict",
210        Mode::Lenient => "lenient",
211    }
212}
213
214fn error_kind_str(kind: ValidationErrorKind) -> &'static str {
215    match kind {
216        ValidationErrorKind::Malformed => "malformed",
217        ValidationErrorKind::OutOfRange => "out_of_range",
218    }
219}
220
221fn error_json(err: &ValidationError) -> Value {
222    json!({
223        "kind": error_kind_str(err.kind),
224        "detail": err.detail,
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn base_state<'a>(
233        question: &'a str,
234        urns: &'a [String],
235        answer: &'a str,
236        citations: &'a [u32],
237        errors: &'a [ValidationError],
238    ) -> CallState<'a> {
239        CallState {
240            ts_nanos: 1_700_000_000_000_000_000,
241            tenant: "acme",
242            user: "alice",
243            role: "analyst",
244            question,
245            sources_urns: urns,
246            provider: "openai",
247            model: "gpt-4o-mini",
248            prompt_tokens: 123,
249            completion_tokens: 45,
250            cost_usd: 0.0012,
251            answer,
252            citations,
253            cache_hit: false,
254            effective_mode: Mode::Strict,
255            temperature: Some(0.0),
256            seed: Some(42),
257            validation_ok: true,
258            retry_count: 0,
259            errors,
260            intent: None,
261            plan_summary: None,
262            executed_query: None,
263        }
264    }
265
266    #[test]
267    fn planner_fields_present_only_when_set() {
268        let urns: Vec<String> = vec![];
269        let citations: Vec<u32> = vec![];
270        let errors: Vec<ValidationError> = vec![];
271        let mut state = base_state("q?", &urns, "answer", &citations, &errors);
272        let row = build(&state, Settings::default());
273        assert!(!row.contains_key("intent"));
274        assert!(!row.contains_key("plan_summary"));
275        assert!(!row.contains_key("executed_query"));
276
277        state.intent = Some("factual");
278        state.plan_summary = Some("intent=factual; query=SELECT * FROM t WHERE a = 'x'");
279        state.executed_query = Some("SELECT * FROM t WHERE a = 'x'");
280        let row = build(&state, Settings::default());
281        assert_eq!(row.get("intent"), Some(&json!("factual")));
282        assert_eq!(
283            row.get("executed_query"),
284            Some(&json!("SELECT * FROM t WHERE a = 'x'"))
285        );
286        assert!(row.contains_key("plan_summary"));
287    }
288
289    // ---- answer_hash ----------------------------------------------------
290
291    #[test]
292    fn answer_hash_is_deterministic_sha256() {
293        // Known SHA-256 of empty string.
294        assert_eq!(
295            answer_hash(""),
296            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
297        );
298    }
299
300    #[test]
301    fn answer_hash_known_value_for_short_string() {
302        // sha256("hello") — pinned so a regression in hashing is loud.
303        assert_eq!(
304            answer_hash("hello"),
305            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
306        );
307    }
308
309    #[test]
310    fn answer_hash_repeated_calls_byte_equal() {
311        let a = answer_hash("the cat sat on the mat");
312        let b = answer_hash("the cat sat on the mat");
313        assert_eq!(a, b);
314    }
315
316    #[test]
317    fn answer_hash_differs_for_differing_input() {
318        assert_ne!(answer_hash("a"), answer_hash("b"));
319    }
320
321    // ---- core schema ---------------------------------------------------
322
323    #[test]
324    fn build_emits_every_required_field() {
325        let urns = vec!["urn:a".to_string(), "urn:b".to_string()];
326        let citations = vec![1u32, 2];
327        let errors: Vec<ValidationError> = vec![];
328        let state = base_state("q?", &urns, "answer text", &citations, &errors);
329
330        let row = build(&state, Settings::default());
331
332        for key in [
333            "ts",
334            "tenant",
335            "user",
336            "role",
337            "question",
338            "sources_urns",
339            "provider",
340            "model",
341            "prompt_tokens",
342            "completion_tokens",
343            "cost_usd",
344            "answer_hash",
345            "citations",
346            "cache_hit",
347            "mode",
348            "temperature",
349            "seed",
350            "validation_ok",
351            "retry_count",
352            "errors",
353        ] {
354            assert!(row.contains_key(key), "row missing required field `{key}`");
355        }
356    }
357
358    #[test]
359    fn build_field_values_match_state() {
360        let urns = vec!["urn:x".to_string()];
361        let citations = vec![3u32];
362        let errors: Vec<ValidationError> = vec![];
363        let state = base_state("why?", &urns, "because", &citations, &errors);
364
365        let row = build(&state, Settings::default());
366
367        assert_eq!(row["ts"], json!(1_700_000_000_000_000_000_i64));
368        assert_eq!(row["tenant"], json!("acme"));
369        assert_eq!(row["user"], json!("alice"));
370        assert_eq!(row["role"], json!("analyst"));
371        assert_eq!(row["question"], json!("why?"));
372        assert_eq!(row["sources_urns"], json!(["urn:x"]));
373        assert_eq!(row["provider"], json!("openai"));
374        assert_eq!(row["model"], json!("gpt-4o-mini"));
375        assert_eq!(row["prompt_tokens"], json!(123));
376        assert_eq!(row["completion_tokens"], json!(45));
377        assert_eq!(row["cost_usd"], json!(0.0012));
378        assert_eq!(row["answer_hash"], json!(answer_hash("because")));
379        assert_eq!(row["citations"], json!([3]));
380        assert_eq!(row["cache_hit"], json!(false));
381        assert_eq!(row["mode"], json!("strict"));
382        assert_eq!(row["temperature"], json!(0.0));
383        assert_eq!(row["seed"], json!(42u64));
384        assert_eq!(row["validation_ok"], json!(true));
385        assert_eq!(row["retry_count"], json!(0));
386        assert_eq!(row["errors"], json!([]));
387    }
388
389    #[test]
390    fn unsupported_determinism_knobs_are_recorded_as_null() {
391        let urns: Vec<String> = vec![];
392        let citations: Vec<u32> = vec![];
393        let errors: Vec<ValidationError> = vec![];
394        let mut state = base_state("q", &urns, "a", &citations, &errors);
395        state.temperature = None;
396        state.seed = None;
397
398        let row = build(&state, Settings::default());
399
400        assert_eq!(row["temperature"], Value::Null);
401        assert_eq!(row["seed"], Value::Null);
402    }
403
404    // ---- include_answer toggle -----------------------------------------
405
406    #[test]
407    fn answer_field_absent_by_default() {
408        let urns: Vec<String> = vec![];
409        let citations: Vec<u32> = vec![];
410        let errors: Vec<ValidationError> = vec![];
411        let state = base_state("q", &urns, "secret answer", &citations, &errors);
412
413        let row = build(&state, Settings::default());
414
415        assert!(!row.contains_key("answer"));
416        // Hash is still recorded — operators can compare hashes
417        // without the answer itself.
418        assert_eq!(row["answer_hash"], json!(answer_hash("secret answer")));
419    }
420
421    #[test]
422    fn answer_field_present_when_include_answer_set() {
423        let urns: Vec<String> = vec![];
424        let citations: Vec<u32> = vec![];
425        let errors: Vec<ValidationError> = vec![];
426        let state = base_state("q", &urns, "full text", &citations, &errors);
427
428        let row = build(
429            &state,
430            Settings {
431                include_answer: true,
432            },
433        );
434
435        assert_eq!(row["answer"], json!("full text"));
436        // Hash is still present — toggling the flag must not silently
437        // remove other fields.
438        assert_eq!(row["answer_hash"], json!(answer_hash("full text")));
439    }
440
441    // ---- mode + validation --------------------------------------------
442
443    #[test]
444    fn lenient_mode_serializes_as_lenient_string() {
445        let urns: Vec<String> = vec![];
446        let citations: Vec<u32> = vec![];
447        let errors: Vec<ValidationError> = vec![];
448        let mut state = base_state("q", &urns, "a", &citations, &errors);
449        state.effective_mode = Mode::Lenient;
450
451        let row = build(&state, Settings::default());
452
453        assert_eq!(row["mode"], json!("lenient"));
454    }
455
456    #[test]
457    fn errors_round_trip_with_kind_and_detail() {
458        let urns: Vec<String> = vec![];
459        let citations: Vec<u32> = vec![];
460        let errors = vec![
461            ValidationError {
462                kind: ValidationErrorKind::Malformed,
463                detail: "empty marker body".to_string(),
464            },
465            ValidationError {
466                kind: ValidationErrorKind::OutOfRange,
467                detail: "marker [^9] references source #9".to_string(),
468            },
469        ];
470        let mut state = base_state("q", &urns, "a", &citations, &errors);
471        state.validation_ok = false;
472        state.retry_count = 1;
473
474        let row = build(&state, Settings::default());
475
476        assert_eq!(row["validation_ok"], json!(false));
477        assert_eq!(row["retry_count"], json!(1));
478        assert_eq!(
479            row["errors"],
480            json!([
481                json!({"kind": "malformed", "detail": "empty marker body"}),
482                json!({"kind": "out_of_range", "detail": "marker [^9] references source #9"}),
483            ])
484        );
485    }
486
487    // ---- cache hit ------------------------------------------------------
488
489    #[test]
490    fn cache_hit_recorded() {
491        let urns: Vec<String> = vec![];
492        let citations: Vec<u32> = vec![];
493        let errors: Vec<ValidationError> = vec![];
494        let mut state = base_state("q", &urns, "cached", &citations, &errors);
495        state.cache_hit = true;
496        state.prompt_tokens = 0;
497        state.completion_tokens = 0;
498        state.cost_usd = 0.0;
499
500        let row = build(&state, Settings::default());
501
502        assert_eq!(row["cache_hit"], json!(true));
503        // Cost is recorded even when zero — downstream sum() must not
504        // see a missing-field surprise.
505        assert_eq!(row["cost_usd"], json!(0.0));
506        assert_eq!(row["prompt_tokens"], json!(0));
507    }
508
509    // ---- edge cases ----------------------------------------------------
510
511    #[test]
512    fn empty_identity_fields_allowed() {
513        // Embedded use with no auth context — we still want the row
514        // to land. Empty strings serialize as empty strings, not null.
515        let urns: Vec<String> = vec![];
516        let citations: Vec<u32> = vec![];
517        let errors: Vec<ValidationError> = vec![];
518        let mut state = base_state("q", &urns, "a", &citations, &errors);
519        state.tenant = "";
520        state.user = "";
521        state.role = "";
522
523        let row = build(&state, Settings::default());
524
525        assert_eq!(row["tenant"], json!(""));
526        assert_eq!(row["user"], json!(""));
527        assert_eq!(row["role"], json!(""));
528    }
529
530    #[test]
531    fn empty_sources_serializes_as_empty_array() {
532        let urns: Vec<String> = vec![];
533        let citations: Vec<u32> = vec![];
534        let errors: Vec<ValidationError> = vec![];
535        let state = base_state("q", &urns, "a", &citations, &errors);
536
537        let row = build(&state, Settings::default());
538
539        assert_eq!(row["sources_urns"], json!([]));
540        assert_eq!(row["citations"], json!([]));
541        assert_eq!(row["errors"], json!([]));
542    }
543
544    #[test]
545    fn sources_order_preserved() {
546        // RRF (#398) emits a *ranked* list — the audit row MUST keep
547        // that ranking so post-hoc analysis of "what did the model
548        // see, in what order" stays honest.
549        let urns = vec![
550            "urn:c".to_string(),
551            "urn:a".to_string(),
552            "urn:b".to_string(),
553        ];
554        let citations: Vec<u32> = vec![];
555        let errors: Vec<ValidationError> = vec![];
556        let state = base_state("q", &urns, "a", &citations, &errors);
557
558        let row = build(&state, Settings::default());
559
560        assert_eq!(row["sources_urns"], json!(["urn:c", "urn:a", "urn:b"]));
561    }
562
563    #[test]
564    fn build_is_deterministic_across_calls() {
565        // Same inputs → byte-equal rows. Required by the ASK
566        // determinism contract (#400): the audit trail must not
567        // depend on map-randomization or any clock side-effect.
568        let urns = vec!["urn:a".to_string()];
569        let citations = vec![1u32];
570        let errors: Vec<ValidationError> = vec![];
571        let state = base_state("q", &urns, "a", &citations, &errors);
572
573        let a = build(&state, Settings::default());
574        let b = build(&state, Settings::default());
575        assert_eq!(a, b);
576    }
577}