@@ -5,6 +5,7 @@ use calamine::{Reader, open_workbook, Xlsx, Data};
55use serde:: { Deserialize , Serialize } ;
66
77use crate :: classify:: { TaxCategory , Flag } ;
8+ use crate :: validation:: { CommitGate , Disposition , Issue , MetaCtx } ;
89use strum:: VariantArray ;
910
1011pub 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+
6381pub 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 ) ]
269392pub 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