#![allow(dead_code)]
use crate::{CliError, CliResult};
use serde::{Deserialize, Serialize};
pub const OVERLAY_RECORD_TYPE: &str = "participant-overlay";
pub const ACTOR_KINDS: &[&str] = &["human", "agent", "external"];
pub const INTEGRITY_TRAITS: &[&str] = &[
"independent",
"can_edit_source",
"non_iterating",
"can_verify_gate",
"can_merge_approve",
];
#[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 {
pub fn record_type(&self) -> &'static str {
OVERLAY_RECORD_TYPE
}
pub fn overlay_kind(&self) -> &'static str {
match self {
Overlay::ActorKind { .. } => "actor-kind",
Overlay::Role { .. } => "role",
Overlay::Trait { .. } => "trait",
}
}
pub fn subject(&self) -> &str {
match self {
Overlay::ActorKind { subject, .. }
| Overlay::Role { subject, .. }
| Overlay::Trait { subject, .. } => subject,
}
}
pub fn asserter(&self) -> &str {
match self {
Overlay::ActorKind { asserter, .. }
| Overlay::Role { asserter, .. }
| Overlay::Trait { asserter, .. } => asserter,
}
}
pub fn asserter_kind(&self) -> &str {
match self {
Overlay::ActorKind { asserter_kind, .. }
| Overlay::Role { asserter_kind, .. }
| Overlay::Trait { asserter_kind, .. } => asserter_kind,
}
}
pub fn supersedes(&self) -> Option<&str> {
match self {
Overlay::ActorKind { supersedes, .. }
| Overlay::Role { supersedes, .. }
| Overlay::Trait { supersedes, .. } => supersedes.as_deref(),
}
}
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(())
}
pub fn to_storage(&self) -> String {
serde_norway::to_string(self).expect("overlay serialize")
}
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");
}
}