zynk 1.1.0

Portable protocol and helper CLI for multi-agent collaboration.
// T2 builds the typed overlay model ahead of its producer/projection wiring;
// later Track B tasks (the `report`/`decide` overlay producers + DB projection)
// consume these items, so the whole module is dead from the crate's POV until then.
#![allow(dead_code)]

use crate::{CliError, CliResult};
use serde::{Deserialize, Serialize};

/// ADR 036: the storage `record_type` discriminator for every participant overlay
/// (actor-kind / role / trait). The single source of truth shared by all three
/// `Overlay` variants — kept consistent with `Overlay::record_type()`.
pub const OVERLAY_RECORD_TYPE: &str = "participant-overlay";

/// ADR 036: the canonical actor-kind set an `actor-kind` overlay may assert.
pub const ACTOR_KINDS: &[&str] = &["human", "agent", "external"];

/// ADR 036: the canonical integrity-trait ids a `trait` overlay may assert. These
/// are operator-grade (ADR 036 C3) and never self-granted (ADR 024).
pub const INTEGRITY_TRAITS: &[&str] = &[
    "independent",
    "can_edit_source",
    "non_iterating",
    "can_verify_gate",
    "can_merge_approve",
];

/// ADR 036: the typed participant-overlay model. A `#[serde(tag = "overlay")]` enum
/// mirroring the `Decision` pattern (per-variant `validate()` plus serde_norway
/// `to_storage`/`from_storage` round-trips). One overlay asserts a single fact about
/// a `subject` participant, attributed to an `asserter`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "overlay")]
pub enum Overlay {
    #[serde(rename = "actor-kind")]
    ActorKind {
        subject: String,
        asserter: String,
        asserter_kind: String,
        actor_kind: String,
        supersedes: Option<String>,
    },
    #[serde(rename = "role")]
    Role {
        subject: String,
        asserter: String,
        asserter_kind: String,
        role_id: String,
        role_label: String,
        supersedes: Option<String>,
    },
    #[serde(rename = "trait")]
    Trait {
        subject: String,
        asserter: String,
        asserter_kind: String,
        trait_id: String,
        value: bool,
        supersedes: Option<String>,
    },
}

impl Overlay {
    /// The storage `record_type` discriminator (shared across all overlay variants).
    pub fn record_type(&self) -> &'static str {
        OVERLAY_RECORD_TYPE
    }

    /// The overlay-kind discriminator (`actor-kind` / `role` / `trait`).
    pub fn overlay_kind(&self) -> &'static str {
        match self {
            Overlay::ActorKind { .. } => "actor-kind",
            Overlay::Role { .. } => "role",
            Overlay::Trait { .. } => "trait",
        }
    }

    /// The participant this overlay asserts a fact about.
    pub fn subject(&self) -> &str {
        match self {
            Overlay::ActorKind { subject, .. }
            | Overlay::Role { subject, .. }
            | Overlay::Trait { subject, .. } => subject,
        }
    }

    /// The participant that asserted this overlay.
    pub fn asserter(&self) -> &str {
        match self {
            Overlay::ActorKind { asserter, .. }
            | Overlay::Role { asserter, .. }
            | Overlay::Trait { asserter, .. } => asserter,
        }
    }

    /// The asserter grade (`operator` for Track B's `assign`; `profile` is reserved for
    /// Track C). Bound into the typed `participant_overlay.asserter_kind` column so the
    /// projection carries the real grade rather than a hardcoded literal.
    pub fn asserter_kind(&self) -> &str {
        match self {
            Overlay::ActorKind { asserter_kind, .. }
            | Overlay::Role { asserter_kind, .. }
            | Overlay::Trait { asserter_kind, .. } => asserter_kind,
        }
    }

    /// The audit id this overlay supersedes, if any.
    pub fn supersedes(&self) -> Option<&str> {
        match self {
            Overlay::ActorKind { supersedes, .. }
            | Overlay::Role { supersedes, .. }
            | Overlay::Trait { supersedes, .. } => supersedes.as_deref(),
        }
    }

    /// ADR 036: validate the bound per-variant contract. Reject malformed BEFORE any
    /// write (ADR 029 discipline) — never store-bad / silently-drop.
    pub fn validate(&self) -> CliResult<()> {
        match self {
            Overlay::ActorKind { actor_kind, .. } => {
                if !ACTOR_KINDS.contains(&actor_kind.as_str()) {
                    return Err(CliError::usage(format!(
                        "actor_kind must be one of {}",
                        ACTOR_KINDS.join("/")
                    )));
                }
            }
            Overlay::Role {
                role_id,
                role_label,
                ..
            } => {
                if role_id.trim().is_empty() || role_label.trim().is_empty() {
                    return Err(CliError::usage(
                        "role requires non-empty role_id + role_label",
                    ));
                }
            }
            Overlay::Trait {
                subject,
                asserter,
                asserter_kind,
                trait_id,
                ..
            } => {
                if !INTEGRITY_TRAITS.contains(&trait_id.as_str()) {
                    return Err(CliError::usage(format!(
                        "trait must be one of {}",
                        INTEGRITY_TRAITS.join("/")
                    )));
                }
                if asserter == subject {
                    return Err(CliError::usage(
                        "an integrity trait cannot be self-granted (ADR 024)",
                    ));
                }
                if asserter_kind != "operator" {
                    return Err(CliError::usage(
                        "an integrity trait requires an operator-grade asserter (ADR 036 C3)",
                    ));
                }
            }
        }
        Ok(())
    }

    /// Serialize to the storage form (serde_norway YAML — zynk's existing dep, no new
    /// deps). Round-trips back via `from_storage`.
    pub fn to_storage(&self) -> String {
        serde_norway::to_string(self).expect("overlay serialize")
    }

    /// The round-trip counterpart of `to_storage`.
    pub fn from_storage(s: &str) -> CliResult<Self> {
        serde_norway::from_str(s)
            .map_err(|e| CliError::usage(format!("invalid overlay payload: {e}")))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn trait_self_grant_rejected() {
        let o = Overlay::Trait {
            subject: "claude".into(),
            asserter: "claude".into(),
            asserter_kind: "operator".into(),
            trait_id: "independent".into(),
            value: true,
            supersedes: None,
        };
        assert!(
            o.validate().is_err(),
            "an integrity trait cannot be self-granted"
        );
    }

    #[test]
    fn trait_unknown_id_rejected() {
        let o = Overlay::Trait {
            subject: "claude".into(),
            asserter: "operator".into(),
            asserter_kind: "operator".into(),
            trait_id: "bogus".into(),
            value: true,
            supersedes: None,
        };
        assert!(o.validate().is_err(), "unknown trait_id must be rejected");
    }

    #[test]
    fn trait_non_operator_asserter_rejected() {
        let o = Overlay::Trait {
            subject: "claude".into(),
            asserter: "codex".into(),
            asserter_kind: "profile".into(),
            trait_id: "independent".into(),
            value: true,
            supersedes: None,
        };
        assert!(
            o.validate().is_err(),
            "a non-operator asserter must be rejected (ADR 036 C3)"
        );
    }

    #[test]
    fn actor_kind_unknown_rejected() {
        let bad = Overlay::ActorKind {
            subject: "claude".into(),
            asserter: "operator".into(),
            asserter_kind: "operator".into(),
            actor_kind: "robot".into(),
            supersedes: None,
        };
        assert!(bad.validate().is_err(), "unknown actor_kind must reject");
        let ok = Overlay::ActorKind {
            subject: "zevs".into(),
            asserter: "operator".into(),
            asserter_kind: "operator".into(),
            actor_kind: "human".into(),
            supersedes: None,
        };
        assert!(ok.validate().is_ok(), "human is a valid actor_kind");
    }

    #[test]
    fn role_requires_id_and_label() {
        let empty_id = Overlay::Role {
            subject: "claude".into(),
            asserter: "operator".into(),
            asserter_kind: "operator".into(),
            role_id: "".into(),
            role_label: "Reviewer".into(),
            supersedes: None,
        };
        assert!(empty_id.validate().is_err(), "empty role_id must reject");
        let empty_label = Overlay::Role {
            subject: "claude".into(),
            asserter: "operator".into(),
            asserter_kind: "operator".into(),
            role_id: "reviewer".into(),
            role_label: "  ".into(),
            supersedes: None,
        };
        assert!(
            empty_label.validate().is_err(),
            "empty role_label must reject"
        );
    }

    #[test]
    fn overlay_round_trips() {
        let samples = [
            Overlay::ActorKind {
                subject: "zevs".into(),
                asserter: "operator".into(),
                asserter_kind: "operator".into(),
                actor_kind: "human".into(),
                supersedes: None,
            },
            Overlay::Role {
                subject: "claude".into(),
                asserter: "operator".into(),
                asserter_kind: "operator".into(),
                role_id: "reviewer".into(),
                role_label: "Reviewer".into(),
                supersedes: Some("prev-1".into()),
            },
            Overlay::Trait {
                subject: "claude".into(),
                asserter: "operator".into(),
                asserter_kind: "operator".into(),
                trait_id: "independent".into(),
                value: true,
                supersedes: None,
            },
        ];
        for o in samples {
            let back = Overlay::from_storage(&o.to_storage()).unwrap();
            assert_eq!(o, back, "overlay must round-trip through storage");
        }
    }

    #[test]
    fn overlay_record_type_is_participant_overlay() {
        assert_eq!(OVERLAY_RECORD_TYPE, "participant-overlay");
        let sample = Overlay::Trait {
            subject: "claude".into(),
            asserter: "operator".into(),
            asserter_kind: "operator".into(),
            trait_id: "independent".into(),
            value: true,
            supersedes: None,
        };
        assert_eq!(sample.record_type(), "participant-overlay");
    }
}