zynk 1.0.0

Portable protocol and helper CLI for multi-agent collaboration.
use crate::audit::AuditArgs;
use crate::{custody, CliError, CliResult};
use clap::Args;
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::{Path, PathBuf};

/// `zynk reveal <audit_id>` — the operator un-redaction (ADR 034 D8). Decrypt the
/// retained ciphertext in memory, re-verify the plaintext hash against the recorded
/// disclosure contract, write an operator-verified `reveal` proof FIRST, and ONLY
/// THEN emit the plaintext to stdout. Proof-before-disclosure is load-bearing: if the
/// proof write fails, NO plaintext is ever emitted — there is no un-audited reveal.
#[derive(Debug, Args)]
pub struct RevealArgs {
    #[arg(
        long,
        default_value = "outputs",
        help = "runtime outputs root where the reveal proof's audit.md is appended"
    )]
    pub root: PathBuf,
    #[arg(
        long,
        help = "live DB path holding the custody vault; defaults to the cwd .zynk/zynk.db (reveal needs the DB — there is no file-only reveal)"
    )]
    pub db: Option<PathBuf>,
    #[arg(
        long,
        help = "path to the operator-owned custody key file (default <db-dir>/custody.key or $ZYNK_CUSTODY_KEY_FILE)"
    )]
    pub custody_key_file: Option<PathBuf>,
    #[arg(
        long,
        default_value = "operator",
        help = "actor recorded as the source/observer of the reveal proof"
    )]
    pub actor: String,
    #[arg(long)]
    pub timestamp: Option<String>,
    #[arg(help = "the audit_id of the retained record to reveal")]
    pub audit_id: String,
}

pub fn run(args: RevealArgs) -> CliResult<()> {
    // Reveal needs the live DB (the vault). There is NO `--no-db` reveal: this is a
    // read + an audited proof write, both against the DB. Resolve the default/explicit
    // target (no_db=false) and require the DB file to actually exist — the read-only
    // open never auto-creates, so a missing DB is a clear, early error rather than a
    // confusing open failure.
    let db_path = crate::db::resolve_projection_target(args.db.as_deref(), false)
        .into_path_and_mode()
        .map(|(path, _explicit)| path)
        .ok_or_else(|| CliError::failure("reveal needs the live DB"))?;
    if !db_path.exists() {
        return Err(CliError::failure(format!(
            "reveal needs the live DB, but none was found at {} (pass --db <path>)",
            db_path.display()
        )));
    }
    let conn = crate::db::open_read_database(&db_path)?;

    // Revealable? A record with no retained custody (legacy, or written without
    // --retain-custody) is NOT revealable — fail loud, emit nothing.
    let vault = crate::db::read_custody_vault(&conn, &args.audit_id)?.ok_or_else(|| {
        CliError::usage(format!(
            "{} is not revealable (no retained custody)",
            args.audit_id
        ))
    })?;

    // Crypto agility (ADR 034 D6): an unsupported cipher/key version fails loud — we
    // never attempt to decrypt something this build can't verify.
    if vault.cipher_id != custody::CUSTODY_CIPHER_ID
        || vault.key_version != custody::CUSTODY_KEY_VERSION
    {
        return Err(CliError::failure(format!(
            "{} uses unsupported custody cipher/version ({}/{})",
            args.audit_id, vault.cipher_id, vault.key_version
        )));
    }

    // The reveal proof lands in the revealed record's OWN session; payload_hash is the
    // disclosure contract. One read so the two can never diverge.
    let (session_id, payload_hash) = crate::db::read_reveal_target(&conn, &args.audit_id)?
        .ok_or_else(|| CliError::failure(format!("no audit record for {}", args.audit_id)))?;

    // Load the operator key (reveal never creates a key) and decrypt IN MEMORY.
    let key_path = custody::resolve_key_path(args.custody_key_file.as_deref(), &db_path);
    let key = custody::load_existing_key(&key_path)?;
    let plaintext = custody::decrypt(
        &key,
        &args.audit_id,
        &payload_hash,
        &vault.nonce,
        &vault.ciphertext,
    )?;

    // ADR 034 D4: re-verify the plaintext hash. AEAD success alone is NOT sufficient —
    // the recorded payload_hash is the disclosure contract, so a decrypted plaintext
    // whose sha256 differs from it is a loud abort (no proof, no plaintext).
    let recomputed = format!("sha256:{:x}", Sha256::digest(&plaintext));
    if recomputed != payload_hash {
        return Err(CliError::failure(format!(
            "{} reveal aborted: decrypted plaintext hash does not match the recorded record",
            args.audit_id
        )));
    }

    // PROOF BEFORE DISCLOSURE: write the operator-verified reveal proof FIRST. If this
    // returns Err, run() returns it here — BEFORE any stdout write — so no plaintext is
    // ever emitted for an unaudited reveal.
    write_reveal_proof(&args, &db_path, &session_id)?;

    // ONLY now emit the plaintext (the single deliberate disclosure of the recovered
    // payload — it goes to stdout ONLY, never to any DB column / corpus / the proof).
    std::io::stdout()
        .write_all(&plaintext)
        .map_err(|error| CliError::failure(format!("failed to write plaintext: {error}")))?;
    Ok(())
}

/// Write the operator-verified `reveal` proof via the SAME audited path `decide`/`audit`
/// use (validate → file-first → project). The proof is a NON-transport operator sentinel
/// (ADR 024: `delivery_status=observed`, never `sent`; `verified_by=operator`). Its
/// stored payload is a NON-sensitive descriptor (`revealed <audit_id>`) — NEVER the
/// plaintext. Returns Err if any of validate/write/project fails, so the caller can
/// abort before disclosure.
fn write_reveal_proof(args: &RevealArgs, db_path: &Path, session_id: &str) -> CliResult<()> {
    let proof = AuditArgs {
        profile: None,
        root: args.root.clone(),
        session_id: session_id.to_string(),
        audit_id: None,
        previous_audit_id: None,
        timestamp: args.timestamp.clone(),
        due: None,
        source_agent: args.actor.clone(),
        source_address: "cli".to_string(),
        // v1 M3a R1 P1: NON-transport operator record — use the M2a `decide`-style
        // "none" sentinels (see src/decide.rs), NOT empty strings. Empty participant
        // fields flow through render_record's Some(target_agent) into a bogus
        // `agents.agent_id=''` row and a `:` known-target that pollutes the ADR 031
        // composer/write allow-list.
        target_agent: "none".to_string(),
        target_address: "none".to_string(),
        transport: "none".to_string(),
        workspace_id: "none".to_string(),
        transport_thread_id: None,
        mid: format!("reveal-{}", args.audit_id),
        record_type: "reveal".to_string(),
        mode: None,
        // The reveal proof points at the revealed record.
        r#ref: Some(args.audit_id.clone()),
        re: None,
        command_origin: "operator".to_string(),
        // A NON-sensitive descriptor — NEVER the plaintext.
        payload: Some(format!("revealed {}", args.audit_id)),
        payload_file: None,
        // `full` so the (non-sensitive) descriptor is the recorded excerpt; the
        // plaintext is never any part of this record.
        payload_redaction_policy: "full".to_string(),
        payload_ref: None,
        sensitive_category: None,
        excerpt_chars: 12,
        delivery_status: "observed".to_string(),
        observed_by: args.actor.clone(),
        verified_by: "operator".to_string(),
        db: Some(db_path.to_path_buf()),
        no_db: false,
        retain_custody: false,
        custody_key_file: None,
    };
    let profile = crate::profile::load_profile(proof.profile.as_deref())?;
    crate::audit::validate_audit_args(&proof, &profile)?;
    let (_audit_path, record) = crate::audit::write_audit_file(&proof, &profile)?;
    crate::audit::project_record(proof.db.as_deref(), proof.no_db, &proof.root, &record)
}