Skip to content

Commit 0539143

Browse files
promptexecutionerrClaude Sonnet (coordinator)claude
authored
feat(prd-7): materialize AUDIT.log sheet — AuditRow, 9 columns, MetaFlag Display (#57) (#92)
* fix(ledger-core): remove conflicting From impl in ingest.rs From<&TransactionInput> for DocumentFields conflicted with Rust's blanket TryFrom via Into. Keep only TryFrom (the fallible parse-aware version). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(prd-7): materialize AUDIT.log sheet — AuditRow, 9 columns, MetaFlag Display Closes #57. Makes the PRD-7 verification layer CPA-visible: every committed transaction now emits a row in AUDIT.log with the full verification provenance. - validation.rs: add Display for MetaFlag (all 5 variants) - workbook.rs: AuditRow struct + constructor (blake3 entry_id, worst-case disposition, legal_violation scan, serde_json stage trace, comma-joined flags) - workbook.rs: setup_audit_sheet() with 9 headers; stage_trace_json column pinned to width 8 to avoid overwhelming the CPA view - workbook.rs: wire setup_audit_sheet into initialize_workbook + copy_all_sheets - workbook.rs: WorkbookWriter::append_audit_row() - 3 new tests: legal_violation result, Approved gate, stage_trace_json round-trip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet (coordinator) <coordinator@promptexecution.com.au> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 73d5250 commit 0539143

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

crates/ledger-core/src/validation.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! These types provide a carry-forward validation context that accumulates
33
//! confidence and issues through each pipeline stage.
44
5+
use std::fmt;
56
use serde::{Deserialize, Serialize};
67

78
/// Disposition classifies how an issue should be handled by the pipeline.
@@ -107,6 +108,17 @@ pub enum MetaFlag {
107108
LowUpstreamConf { score: f32, stage: String },
108109
ConstraintWeak { constraint: String },
109110
}
111+
impl fmt::Display for MetaFlag {
112+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113+
match self {
114+
MetaFlag::NewVendor { vendor } => write!(f, "new_vendor:{vendor}"),
115+
MetaFlag::AnomalyDetected { code, impact } => write!(f, "anomaly:{code}:{impact:.2}"),
116+
MetaFlag::RepairApplied { rule_id } => write!(f, "repair:{rule_id}"),
117+
MetaFlag::LowUpstreamConf { score, stage } => write!(f, "low_conf:{stage}:{score:.2}"),
118+
MetaFlag::ConstraintWeak { constraint } => write!(f, "constraint_weak:{constraint}"),
119+
}
120+
}
121+
}
110122

111123
/// Score from a single pipeline stage.
112124
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

crates/ledger-core/src/workbook.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use calamine::{Reader, open_workbook, Xlsx, Data};
55
use serde::{Deserialize, Serialize};
66

77
use crate::classify::{TaxCategory, Flag};
8+
use crate::validation::{CommitGate, Disposition, Issue, MetaCtx};
89
use strum::VariantArray;
910

1011
pub const REQUIRED_SHEETS: &[&str] = &[
@@ -30,6 +31,8 @@ pub fn initialize_workbook(path: &Path) -> Result<(), rust_xlsxwriter::XlsxError
3031

3132
if *sheet_name == TRANSACTIONS_SHEET {
3233
setup_transactions_sheet(worksheet)?;
34+
} else if *sheet_name == "AUDIT.log" {
35+
setup_audit_sheet(worksheet)?;
3336
}
3437
}
3538
workbook.save(path)
@@ -60,6 +63,21 @@ fn setup_transactions_sheet(worksheet: &mut Worksheet) -> Result<(), rust_xlsxwr
6063
Ok(())
6164
}
6265

66+
fn setup_audit_sheet(worksheet: &mut Worksheet) -> Result<(), rust_xlsxwriter::XlsxError> {
67+
worksheet.write_string(0, 0, "entry_id")?;
68+
worksheet.write_string(0, 1, "constraint_score")?;
69+
worksheet.write_string(0, 2, "legal_result")?;
70+
worksheet.write_string(0, 3, "disposition")?;
71+
worksheet.write_string(0, 4, "accumulated_confidence")?;
72+
worksheet.write_string(0, 5, "stage_trace_json")?;
73+
worksheet.write_string(0, 6, "flags")?;
74+
worksheet.write_string(0, 7, "invoice_arithmetic_ok")?;
75+
worksheet.write_string(0, 8, "commit_gate")?;
76+
// Keep stage_trace_json narrow so it doesn't overwhelm the CPA view
77+
worksheet.set_column_width(5, 8)?;
78+
Ok(())
79+
}
80+
6381
pub struct WorkbookWriter {
6482
path: PathBuf,
6583
}
@@ -127,6 +145,8 @@ impl WorkbookWriter {
127145
let worksheet = new_workbook.add_worksheet().set_name(*sheet_name)?;
128146
if *sheet_name == TRANSACTIONS_SHEET {
129147
setup_transactions_sheet(worksheet)?;
148+
} else if *sheet_name == "AUDIT.log" {
149+
setup_audit_sheet(worksheet)?;
130150
}
131151
if let Ok(range) = workbook.worksheet_range(*sheet_name) {
132152
Self::copy_sheet_data(worksheet, &range)?;
@@ -215,6 +235,32 @@ impl WorkbookWriter {
215235
Ok(())
216236
}
217237

238+
pub fn append_audit_row(
239+
&self,
240+
row: &AuditRow,
241+
) -> Result<(), Box<dyn std::error::Error>> {
242+
let row_idx = self.get_row_count("AUDIT.log")?;
243+
244+
let mut new_workbook = Workbook::new();
245+
self.copy_all_sheets(&mut new_workbook)?;
246+
247+
let worksheet = Self::find_worksheet_by_name(&mut new_workbook, "AUDIT.log")
248+
.ok_or("AUDIT.log sheet not found")?;
249+
250+
worksheet.write_string(row_idx, 0, &row.entry_id)?;
251+
worksheet.write_number(row_idx, 1, row.constraint_score as f64)?;
252+
worksheet.write_string(row_idx, 2, &row.legal_result)?;
253+
worksheet.write_string(row_idx, 3, &row.disposition)?;
254+
worksheet.write_number(row_idx, 4, row.accumulated_confidence as f64)?;
255+
worksheet.write_string(row_idx, 5, &row.stage_trace_json)?;
256+
worksheet.write_string(row_idx, 6, &row.flags)?;
257+
worksheet.write_boolean(row_idx, 7, row.invoice_arithmetic_ok)?;
258+
worksheet.write_string(row_idx, 8, &row.commit_gate)?;
259+
260+
new_workbook.save(&self.path)?;
261+
Ok(())
262+
}
263+
218264
pub fn append_mutation(
219265
&self,
220266
timestamp: &str,
@@ -265,6 +311,83 @@ impl WorkbookWriter {
265311
}
266312
}
267313

314+
/// One audit row per committed transaction for the AUDIT.log sheet.
315+
#[derive(Debug, Clone, Serialize, Deserialize)]
316+
pub struct AuditRow {
317+
pub entry_id: String,
318+
pub constraint_score: f32,
319+
pub legal_result: String,
320+
pub disposition: String,
321+
pub accumulated_confidence: f32,
322+
pub stage_trace_json: String,
323+
pub flags: String,
324+
pub invoice_arithmetic_ok: bool,
325+
pub commit_gate: String,
326+
}
327+
328+
impl AuditRow {
329+
pub fn new(
330+
document_id: &str,
331+
source_ref: &str,
332+
constraint_score: f32,
333+
issues: &[Issue],
334+
meta: &MetaCtx,
335+
invoice_arithmetic_ok: bool,
336+
gate: &CommitGate,
337+
) -> Self {
338+
let entry_id = {
339+
let input = format!("{document_id}|{source_ref}");
340+
blake3::hash(input.as_bytes()).to_hex().to_string()
341+
};
342+
343+
let legal_result = issues
344+
.iter()
345+
.find(|i| i.code == "legal_violation")
346+
.map(|i| i.code.clone())
347+
.unwrap_or_else(|| "ok".to_string());
348+
349+
let disposition = issues
350+
.iter()
351+
.map(|i| i.disposition)
352+
.max_by_key(|d| match d {
353+
Disposition::Unrecoverable => 2,
354+
Disposition::Recoverable => 1,
355+
Disposition::Advisory => 0,
356+
})
357+
.map(|d| format!("{d:?}").to_ascii_lowercase())
358+
.unwrap_or_else(|| "ok".to_string());
359+
360+
let stage_trace_json = serde_json::to_string(&meta.stage_trace)
361+
.unwrap_or_else(|_| "[]".to_string());
362+
363+
let flags = meta
364+
.flags
365+
.iter()
366+
.map(|f| f.to_string())
367+
.collect::<Vec<_>>()
368+
.join(",");
369+
370+
let commit_gate = match gate {
371+
CommitGate::Approved { .. } => "Approved",
372+
CommitGate::PendingOperator { .. } => "PendingOperator",
373+
CommitGate::Blocked { .. } => "Blocked",
374+
}
375+
.to_string();
376+
377+
Self {
378+
entry_id,
379+
constraint_score,
380+
legal_result,
381+
disposition,
382+
accumulated_confidence: meta.accumulated_confidence,
383+
stage_trace_json,
384+
flags,
385+
invoice_arithmetic_ok,
386+
commit_gate,
387+
}
388+
}
389+
}
390+
268391
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269392
pub struct TxProjectionRow {
270393
pub tx_id: String,
@@ -629,4 +752,46 @@ mod tests {
629752
_ => panic!("Unexpected cell type"),
630753
}
631754
}
755+
#[test]
756+
fn test_audit_row_legal_violation_result() {
757+
use crate::validation::{CommitGate, Disposition, Issue, IssueSource, MetaCtx};
758+
let issues = vec![Issue {
759+
code: "legal_violation".to_string(),
760+
message: "foreign SaaS should have BASEXCLUDED tax code".to_string(),
761+
field: None,
762+
disposition: Disposition::Unrecoverable,
763+
source: IssueSource::TypeCheck,
764+
}];
765+
let meta = MetaCtx::default();
766+
let gate = CommitGate::Blocked { issues: issues.clone() };
767+
let row = AuditRow::new("doc1", "WF--BH--2026-01", 0.0, &issues, &meta, true, &gate);
768+
assert_eq!(row.legal_result, "legal_violation");
769+
assert_eq!(row.commit_gate, "Blocked");
770+
assert_eq!(row.disposition, "unrecoverable");
771+
}
772+
773+
#[test]
774+
fn test_audit_row_approved_gate() {
775+
use crate::validation::{CommitGate, MetaCtx};
776+
let gate = CommitGate::Approved { confidence: 0.95 };
777+
let meta = MetaCtx::default();
778+
let row = AuditRow::new("doc2", "WF--BH--2026-01", 0.95, &[], &meta, true, &gate);
779+
assert_eq!(row.commit_gate, "Approved");
780+
assert_eq!(row.legal_result, "ok");
781+
assert_eq!(row.disposition, "ok");
782+
}
783+
784+
#[test]
785+
fn test_audit_row_stage_trace_json_is_valid() {
786+
use crate::validation::{CommitGate, MetaCtx};
787+
let mut meta = MetaCtx::default();
788+
meta = meta.advance("validate", 0.9, &[]);
789+
meta = meta.advance("verify_legal", 1.0, &[]);
790+
let gate = CommitGate::Approved { confidence: 0.9 };
791+
let row = AuditRow::new("doc3", "src", 1.0, &[], &meta, false, &gate);
792+
let parsed: serde_json::Value = serde_json::from_str(&row.stage_trace_json)
793+
.expect("stage_trace_json must be valid JSON");
794+
assert!(parsed.is_array());
795+
assert_eq!(parsed.as_array().unwrap().len(), 2);
796+
}
632797
}

0 commit comments

Comments
 (0)