use crate::profile::load_profile;
use crate::{CliError, CliResult};
use clap::Args;
use fs2::FileExt;
use rand::{rngs::OsRng, Rng};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs::{self, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::PathBuf;
#[derive(Debug, Args)]
pub struct AuditArgs {
#[arg(long)]
pub profile: Option<PathBuf>,
#[arg(
long,
default_value = "outputs",
help = "runtime outputs root; pass <project>/outputs to write <project>/outputs/sessions/..."
)]
pub root: PathBuf,
#[arg(long)]
pub session_id: String,
#[arg(long)]
pub audit_id: Option<String>,
#[arg(long)]
pub previous_audit_id: Option<String>,
#[arg(long)]
pub timestamp: Option<String>,
#[arg(
long,
help = "optional RFC3339 UTC deadline/expected-by for this record (ADR 027); advisory only"
)]
pub due: Option<String>,
#[arg(long)]
pub source_agent: String,
#[arg(long)]
pub source_address: String,
#[arg(long)]
pub target_agent: String,
#[arg(long)]
pub target_address: String,
#[arg(long)]
pub transport: String,
#[arg(long)]
pub workspace_id: String,
#[arg(long)]
pub transport_thread_id: Option<String>,
#[arg(long)]
pub mid: String,
#[arg(long = "type")]
pub record_type: String,
#[arg(long)]
pub mode: Option<String>,
#[arg(long)]
pub r#ref: Option<String>,
#[arg(long)]
pub re: Option<String>,
#[arg(long, value_parser = ["agent", "operator", "helper-tool", "unknown"])]
pub command_origin: String,
#[arg(long)]
pub payload: Option<String>,
#[arg(long)]
pub payload_file: Option<PathBuf>,
#[arg(long, default_value = "hash-only")]
pub payload_redaction_policy: String,
#[arg(long)]
pub payload_ref: Option<String>,
#[arg(long)]
pub sensitive_category: Option<String>,
#[arg(long, default_value_t = 12)]
pub excerpt_chars: usize,
#[arg(
long,
help = "delivery proof state; use drafted for message-shaped text that was not sent"
)]
pub delivery_status: String,
#[arg(long)]
pub observed_by: String,
#[arg(long, value_parser = ["transport", "agent", "operator", "helper-tool"])]
pub verified_by: String,
#[arg(
long,
help = "live DB path; by default the cwd .zynk/zynk.db is auto-created and the record is projected into it (ADR 028); --no-db forces file-only"
)]
pub db: Option<PathBuf>,
#[arg(
long,
help = "force file-only; skip the DB projection even if a DB exists"
)]
pub no_db: bool,
}
pub fn run(args: AuditArgs) -> CliResult<()> {
let profile = load_profile(args.profile.as_deref())?;
validate_audit_args(&args, &profile)?;
let (audit_path, record) = write_audit_file(&args, &profile)?;
project_record(args.db.as_deref(), args.no_db, &args.root, &record)?;
println!("{}", audit_path.display());
Ok(())
}
pub(crate) fn validate_audit_args(
args: &AuditArgs,
profile: &crate::profile::Profile,
) -> CliResult<()> {
if args.payload.is_some() == args.payload_file.is_some() {
return Err(CliError::usage(
"exactly one of --payload or --payload-file is required",
));
}
if args.excerpt_chars < 1 {
return Err(CliError::usage("--excerpt-chars must be >= 1"));
}
if !profile
.audit
.delivery_status_enum
.contains(&args.delivery_status)
{
return Err(CliError::usage(format!(
"invalid delivery status: {}",
args.delivery_status
)));
}
if args.delivery_status == "sent" && args.verified_by == "agent" {
return Err(CliError::usage(
"delivery_status=sent requires transport, helper-tool, or operator verification",
));
}
if let Some(due) = &args.due {
if crate::timestamp::canonicalize(due).is_none() {
return Err(CliError::usage(format!(
"invalid due timestamp: {due} (expected RFC3339, e.g. 2026-06-01T12:00:00Z)"
)));
}
}
if let Some(timestamp) = &args.timestamp {
if crate::timestamp::canonicalize(timestamp).is_none() {
return Err(CliError::usage(format!(
"invalid timestamp: {timestamp} (expected RFC3339, e.g. 2026-05-29T12:00:00Z)"
)));
}
}
if !profile
.audit
.redaction_policy_enum
.contains(&args.payload_redaction_policy)
{
return Err(CliError::usage(format!(
"invalid redaction policy: {}",
args.payload_redaction_policy
)));
}
Ok(())
}
pub(crate) fn write_audit_file(
args: &AuditArgs,
profile: &crate::profile::Profile,
) -> CliResult<(PathBuf, crate::db::ImportedAuditRecord)> {
let audit_path = args
.root
.join("sessions")
.join(&args.session_id)
.join("audit.md");
if let Some(parent) = audit_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
CliError::failure(format!("failed to create {}: {error}", parent.display()))
})?;
}
let mut handle = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&audit_path)
.map_err(|error| {
CliError::failure(format!("failed to open {}: {error}", audit_path.display()))
})?;
handle.lock_exclusive().map_err(|error| {
CliError::failure(format!("failed to lock {}: {error}", audit_path.display()))
})?;
let result = write_locked_record(args, profile, &mut handle);
let unlock_result = handle.unlock();
if let Err(error) = unlock_result {
return Err(CliError::failure(format!(
"failed to unlock {}: {error}",
audit_path.display()
)));
}
let record = result?;
Ok((audit_path, record))
}
pub(crate) fn project_record(
db: Option<&std::path::Path>,
no_db: bool,
root: &std::path::Path,
record: &crate::db::ImportedAuditRecord,
) -> CliResult<()> {
if let Some((db_path, explicit)) =
crate::db::resolve_projection_target(db, no_db).into_path_and_mode()
{
match crate::db::project_audit(&db_path, root, record) {
Ok(crate::db::LiveAuditProjection::Gap { reason }) => eprintln!(
"warning: audit DB projection gap (file written; db import will reconcile): {reason}"
),
Ok(_) => {}
Err(error) if explicit => return Err(error),
Err(error) => eprintln!(
"warning: audit DB projection skipped (file written): {}",
error.message
),
}
}
Ok(())
}
fn write_locked_record(
args: &AuditArgs,
profile: &crate::profile::Profile,
handle: &mut std::fs::File,
) -> CliResult<crate::db::ImportedAuditRecord> {
handle
.seek(SeekFrom::Start(0))
.map_err(|error| CliError::failure(format!("failed to read audit file: {error}")))?;
let mut existing_content = String::new();
handle
.read_to_string(&mut existing_content)
.map_err(|error| CliError::failure(format!("failed to read audit file: {error}")))?;
let existing_ids = audit_ids_from_text(&existing_content);
let existing_id_set = existing_ids.iter().cloned().collect::<HashSet<_>>();
let audit_id = if let Some(audit_id) = &args.audit_id {
if existing_id_set.contains(audit_id) {
return Err(CliError::usage(format!("duplicate audit_id: {audit_id}")));
}
audit_id.clone()
} else {
generate_audit_id(&existing_id_set)?
};
let previous_audit_id = args
.previous_audit_id
.clone()
.unwrap_or_else(|| last_audit_id(&existing_ids));
let (record_text, imported) = render_record(args, profile, &previous_audit_id, &audit_id)?;
handle
.seek(SeekFrom::End(0))
.map_err(|error| CliError::failure(format!("failed to append audit file: {error}")))?;
if existing_content.is_empty() {
write!(
handle,
"# Audit Trail: {}\n\nsession_id: {}\n\n## Records\n\n",
args.session_id, args.session_id
)
.map_err(|error| CliError::failure(format!("failed to write audit header: {error}")))?;
}
writeln!(handle, "{record_text}")
.map_err(|error| CliError::failure(format!("failed to write audit record: {error}")))?;
Ok(imported)
}
fn render_record(
args: &AuditArgs,
profile: &crate::profile::Profile,
previous_audit_id: &str,
audit_id: &str,
) -> CliResult<(String, crate::db::ImportedAuditRecord)> {
let mut policy = args.payload_redaction_policy.clone();
if let Some(category) = &args.sensitive_category {
if profile.audit.force_hash_only_categories.contains(category) {
policy = "hash-only".to_string();
}
}
let payload = payload_from_args(args)?;
let payload_bytes = payload.as_bytes();
let digest = Sha256::digest(payload_bytes);
let timestamp = args
.timestamp
.clone()
.unwrap_or_else(crate::timestamp::now_utc_seconds);
let excerpt = payload_excerpt(&payload, args.excerpt_chars, &policy);
let due = args
.due
.as_ref()
.and_then(|due| crate::timestamp::canonicalize(due));
let payload_hash = format!("sha256:{digest:x}");
let content_size = payload_bytes.len();
let imported = crate::db::ImportedAuditRecord {
audit_id: audit_id.to_string(),
previous_audit_id: (previous_audit_id != "none").then(|| previous_audit_id.to_string()),
timestamp: timestamp.clone(),
source_agent_id: Some(args.source_agent.clone()),
source_address: args.source_address.clone(),
target_agent_id: Some(args.target_agent.clone()),
target_address: args.target_address.clone(),
transport: args.transport.clone(),
workspace_id: args.workspace_id.clone(),
session_id: args.session_id.clone(),
mid: args.mid.clone(),
record_type: args.record_type.clone(),
command_origin: args.command_origin.clone(),
mode: args.mode.clone(),
r#ref: args.r#ref.clone(),
re: args.re.clone(),
payload_hash: payload_hash.clone(),
payload_redaction_policy: policy.clone(),
content_size: content_size as i64,
delivery_status: args.delivery_status.clone(),
observed_by: args.observed_by.clone(),
verified_by: args.verified_by.clone(),
due: due.clone(),
payload_excerpt: excerpt.clone(),
};
let mut fields: Vec<(String, String)> = vec![
("audit_id".to_string(), audit_id.to_string()),
(
"previous_audit_id".to_string(),
previous_audit_id.to_string(),
),
("timestamp".to_string(), timestamp),
("source_agent".to_string(), args.source_agent.clone()),
("source_address".to_string(), args.source_address.clone()),
("target_agent".to_string(), args.target_agent.clone()),
("target_address".to_string(), args.target_address.clone()),
("transport".to_string(), args.transport.clone()),
("workspace_id".to_string(), args.workspace_id.clone()),
("session_id".to_string(), args.session_id.clone()),
("mid".to_string(), args.mid.clone()),
("type".to_string(), args.record_type.clone()),
("command_origin".to_string(), args.command_origin.clone()),
("payload_hash".to_string(), payload_hash),
("payload_redaction_policy".to_string(), policy),
("content_size".to_string(), content_size.to_string()),
("delivery_status".to_string(), args.delivery_status.clone()),
("observed_by".to_string(), args.observed_by.clone()),
("verified_by".to_string(), args.verified_by.clone()),
];
for (key, value) in [
("transport_thread_id", args.transport_thread_id.as_ref()),
("mode", args.mode.as_ref()),
("ref", args.r#ref.as_ref()),
("re", args.re.as_ref()),
("due", due.as_ref()),
("payload_ref", args.payload_ref.as_ref()),
("payload_excerpt", excerpt.as_ref()),
] {
if let Some(value) = value {
fields.push((key.to_string(), value.clone()));
}
}
let record = fields
.into_iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("\n");
Ok((format!("```text\n{record}\n```"), imported))
}
fn payload_from_args(args: &AuditArgs) -> CliResult<String> {
if let Some(path) = &args.payload_file {
return fs::read_to_string(path).map_err(|error| {
CliError::failure(format!(
"failed to read payload file {}: {error}",
path.display()
))
});
}
Ok(args.payload.clone().unwrap_or_default())
}
fn payload_excerpt(payload: &str, chars: usize, policy: &str) -> Option<String> {
match policy {
"hash-only" => None,
"full" => Some(payload.to_string()),
_ => {
let count = payload.chars().count();
if count <= chars * 2 {
Some(payload.to_string())
} else {
let start = payload.chars().take(chars).collect::<String>();
let end = payload
.chars()
.rev()
.take(chars)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
Some(format!("{start}...{end}"))
}
}
}
}
fn audit_ids_from_text(content: &str) -> Vec<String> {
content
.lines()
.filter_map(|line| line.trim().strip_prefix("audit_id="))
.filter(|id| {
id.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
})
.map(str::to_string)
.collect()
}
fn last_audit_id(existing_ids: &[String]) -> String {
existing_ids
.iter()
.last()
.cloned()
.unwrap_or_else(|| "none".to_string())
}
fn generate_audit_id(existing_ids: &HashSet<String>) -> CliResult<String> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = OsRng;
for _ in 0..100 {
let audit_id = (0..6)
.map(|_| {
let index = rng.gen_range(0..ALPHABET.len());
ALPHABET[index] as char
})
.collect::<String>();
if !existing_ids.contains(&audit_id) {
return Ok(audit_id);
}
}
Err(CliError::usage(
"could not generate unique audit_id after 100 attempts",
))
}