zynk 1.1.0

Portable protocol and helper CLI for multi-agent collaboration.
use crate::audit::AuditArgs;
use crate::overlay::Overlay;
use crate::CliResult;
use clap::{Args, Subcommand};
use std::path::PathBuf;

#[derive(Debug, Args)]
pub struct AssignArgs {
    #[command(subcommand)]
    pub kind: AssignKind,
}

/// Flags shared by every `zynk assign <type>` subcommand, mirroring `decide::Common`:
/// where the file-first audit artifact lands (`--root`), how it projects (`--db` /
/// `--no-db`, ADR 028), and which session/when (`--session-id` / `--timestamp`), plus
/// the participant the overlay is asserted ABOUT (`--subject`). An overlay is
/// operator-authored by definition (ADR 036 C3), so the proof hardcodes
/// `source_agent=operator`; there is no `--asserter` flag (it would be inert).
#[derive(Debug, Args)]
pub struct Common {
    #[arg(long, default_value = "outputs")]
    pub root: PathBuf,
    #[arg(long)]
    pub db: Option<PathBuf>,
    #[arg(long)]
    pub no_db: bool,
    #[arg(long)]
    pub session_id: String,
    #[arg(long)]
    pub timestamp: Option<String>,
    /// The participant this overlay asserts a fact about.
    #[arg(long)]
    pub subject: String,
}

/// The participant-overlay kinds (ADR 036): one subcommand per `Overlay` variant.
/// actor-kind binds `--kind`; role binds `--role-id` + `--role-label`; trait binds
/// `--trait` + an optional `--unset` (sets `trait_value=false`).
#[derive(Debug, Subcommand)]
pub enum AssignKind {
    /// Assert a participant's `actor-kind` (`human` / `agent` / `external`).
    ActorKind {
        #[command(flatten)]
        common: Common,
        #[arg(long)]
        kind: String,
    },
    /// Assert a free-form `role` (an id + a human label) on a participant.
    Role {
        #[command(flatten)]
        common: Common,
        #[arg(long = "role-id")]
        role_id: String,
        #[arg(long = "role-label")]
        role_label: String,
    },
    /// Assert (or `--unset`) an integrity `trait` on a participant. Operator-grade
    /// (ADR 036 C3) and never self-granted (ADR 024) — both rejected before any write.
    Trait {
        #[command(flatten)]
        common: Common,
        #[arg(long = "trait")]
        trait_id: String,
        /// Clear the trait (sets `trait_value=false`) instead of setting it.
        #[arg(long)]
        unset: bool,
    },
}

impl AssignKind {
    fn common(&self) -> &Common {
        match self {
            AssignKind::ActorKind { common, .. }
            | AssignKind::Role { common, .. }
            | AssignKind::Trait { common, .. } => common,
        }
    }

    /// The overlay-kind discriminator the supersede pre-read slots by (mirrors
    /// `Overlay::overlay_kind`, but available before the `Overlay` is built).
    fn overlay_kind(&self) -> &'static str {
        match self {
            AssignKind::ActorKind { .. } => "actor-kind",
            AssignKind::Role { .. } => "role",
            AssignKind::Trait { .. } => "trait",
        }
    }

    /// The `trait_id` this assignment slots by (traits supersede per trait_id);
    /// None for actor-kind/role (they supersede per overlay_kind).
    fn trait_id(&self) -> Option<&str> {
        match self {
            AssignKind::Trait { trait_id, .. } => Some(trait_id.as_str()),
            _ => None,
        }
    }

    /// Build the typed `Overlay` for this subcommand. `asserter`/`asserter_kind` are
    /// hardcoded operator-grade (ADR 036 C3); `supersedes` is resolved by the caller
    /// from the current-slot row and threaded in here.
    fn overlay(&self, supersedes: Option<String>) -> Overlay {
        let subject = self.common().subject.clone();
        match self {
            AssignKind::ActorKind { kind, .. } => Overlay::ActorKind {
                subject,
                asserter: "operator".to_string(),
                asserter_kind: "operator".to_string(),
                actor_kind: kind.clone(),
                supersedes,
            },
            AssignKind::Role {
                role_id,
                role_label,
                ..
            } => Overlay::Role {
                subject,
                asserter: "operator".to_string(),
                asserter_kind: "operator".to_string(),
                role_id: role_id.clone(),
                role_label: role_label.clone(),
                supersedes,
            },
            AssignKind::Trait {
                trait_id, unset, ..
            } => Overlay::Trait {
                subject,
                asserter: "operator".to_string(),
                asserter_kind: "operator".to_string(),
                trait_id: trait_id.clone(),
                // `--unset` clears the trait (value=false); the default sets it.
                value: !*unset,
                supersedes,
            },
        }
    }
}

pub fn run(args: AssignArgs) -> CliResult<()> {
    let kind = args.kind;
    let common = kind.common();

    // Resolve the projection target once (ADR 028): `--no-db` => None; `--db <p>` =>
    // Explicit (hard-fail on projection error); default => the auto-created cwd
    // `.zynk/zynk.db` (soft-degrade). Used BOTH for the supersede pre-read and the
    // post-write projection.
    let target = crate::db::resolve_projection_target(common.db.as_deref(), common.no_db);

    // VALIDATE-BEFORE-WRITE (ADR 029 / trackbrc1 P3): validate the subcommand-local fields
    // FIRST, with `supersedes=None`, so an unknown kind/trait, an empty role, or a
    // self-granted / non-operator trait rejects here with ZERO side effects — no file AND no
    // DB. The supersede pre-read below (`current_overlay_audit_id`) auto-creates/migrates the
    // default `.zynk/zynk.db`, so it MUST run AFTER this validation, never before. The
    // resolved supersedes is threaded back in when we rebuild the overlay for the write.
    kind.overlay(None).validate()?;

    // Supersede resolution (ADR 036): if a DB is in play, find the CURRENT-slot overlay
    // (the row whose forward pointer is still open) for this (session, subject,
    // overlay_kind[, trait_id]); a re-assign / --unset supersedes it, a first assignment
    // finds none. Under `--no-db` (file-only) there is no live row to read, so
    // supersedes=None — `db import` reconstructs ordering from the committed payloads'
    // pointers. The pre-read is non-fatal on a soft-degrade target (a default DB that
    // can't be opened just means "no prior row"); an explicit `--db` surfaces the error.
    let supersedes = match target.path_and_mode() {
        Some((db_path, explicit)) => {
            match crate::db::current_overlay_audit_id(
                db_path,
                &common.session_id,
                &common.subject,
                kind.overlay_kind(),
                kind.trait_id(),
            ) {
                Ok(prev) => prev,
                Err(error) if explicit => return Err(error),
                // Default target soft-degrade: treat an unreadable DB as "no prior row".
                Err(_) => None,
            }
        }
        None => None,
    };

    // Rebuild the typed overlay with the resolved `supersedes` for the write. Re-validate
    // for defensiveness (cheap; the same fields already passed above with supersedes=None,
    // and the threaded supersedes does not change the validation outcome).
    let overlay = kind.overlay(supersedes);
    overlay.validate()?;

    // Build the overlay audit (ADR 033 D4 conventions, shared with `decide`): an
    // operator-observed, operator-verified, transport=none proof carrying the serialized
    // overlay as its full-redaction payload, with `type`=OVERLAY_RECORD_TYPE.
    // delivery_status=observed (never `sent` — an overlay is not a transported message),
    // so the ADR 024 sent+agent boundary never applies; the v10 sent-guard trigger keeps
    // the family out of `sent`.
    let audit_args = AuditArgs {
        profile: None,
        root: common.root.clone(),
        session_id: common.session_id.clone(),
        audit_id: None,
        previous_audit_id: None,
        timestamp: common.timestamp.clone(),
        due: None,
        source_agent: "operator".to_string(),
        source_address: "cli".to_string(),
        target_agent: "none".to_string(),
        target_address: "none".to_string(),
        transport: "none".to_string(),
        workspace_id: "none".to_string(),
        transport_thread_id: None,
        mid: crate::dashboard_write::mint_mid(),
        record_type: crate::overlay::OVERLAY_RECORD_TYPE.to_string(),
        mode: None,
        r#ref: None,
        re: None,
        command_origin: "operator".to_string(),
        payload: Some(overlay.to_storage()),
        payload_file: None,
        payload_redaction_policy: "full".to_string(),
        payload_ref: None,
        sensitive_category: None,
        excerpt_chars: 12,
        delivery_status: "observed".to_string(),
        observed_by: "operator".to_string(),
        verified_by: "operator".to_string(),
        db: common.db.clone(),
        no_db: common.no_db,
        // `zynk assign` does not expose `--retain-custody`; the redacted corpus is
        // unchanged (mirrors `zynk decide`).
        retain_custody: false,
        custody_key_file: None,
    };

    // Reuse the audit machinery: validate the rendered args (boundary + enums + RFC3339
    // timestamp) BEFORE the file write, then write the file (file-first, under the
    // audit.md exclusive lock).
    let profile = crate::profile::load_profile(None)?;
    crate::audit::validate_audit_args(&audit_args, &profile)?;
    let (audit_path, record) = crate::audit::write_audit_file(&audit_args, &profile)?;

    // Project the overlay (the immutable audit proof row + the typed participant_overlay
    // row, atomically, closing the superseded row's forward pointer in the same
    // transaction). An explicit `--db` hard-fails on a projection error (the file is
    // already durable, then nonzero); the default target soft-degrades on an infra
    // failure (the overlay FILE is the authoritative portable record per ADR 027). A
    // soft-degrade (or `--no-db`) leaves the typed row absent for now — `db import`
    // reconstructs it from the overlay audit's full-redaction payload.
    if let Some((db_path, explicit)) = target.into_path_and_mode() {
        let result = crate::db::project_overlay(&db_path, &common.root, &record, &overlay);
        if explicit {
            result?;
        } else if let Err(error) = result {
            eprintln!(
                "warning: DB projection skipped (overlay file written): {}",
                error.message
            );
        }
    }

    println!("{}", audit_path.display());
    Ok(())
}