use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde_json::{Map, Value};
use super::writer::is_valid_principal_actor_id;
use crate::error::{Result, ShoreError};
use crate::model::ActorId;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ActorAttributes {
kind: Option<String>,
roles: BTreeSet<String>,
}
impl ActorAttributes {
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
}
pub fn roles(&self) -> &BTreeSet<String> {
&self.roles
}
pub fn is_kind(&self, kind: &str) -> bool {
matches!(self.kind.as_deref(), Some(k) if is_reserved_kind(k) && k == kind)
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.contains(role)
}
}
pub(crate) const RESERVED_KINDS: &[&str] = &["human", "agent", "service", "reviewer-model"];
fn is_reserved_kind(kind: &str) -> bool {
RESERVED_KINDS.contains(&kind)
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ActorAttributesMap {
actors: BTreeMap<ActorId, ActorAttributes>,
}
impl ActorAttributesMap {
pub fn from_attributes_file(path: impl AsRef<Path>) -> Result<Self> {
let bytes =
std::fs::read(path.as_ref()).map_err(|error| ShoreError::WorkflowInputInvalid {
reason: format!(
"failed to read actor-attributes file {}: {error}",
path.as_ref().display()
),
})?;
actor_attributes_from_value(serde_json::from_slice(&bytes)?)
}
pub fn is_empty(&self) -> bool {
self.actors.is_empty()
}
pub fn with_local_override(mut self, local: ActorAttributesMap) -> ActorAttributesMap {
for (actor, attrs) in local.actors {
self.actors.insert(actor, attrs);
}
self
}
pub fn resolve(&self, actor: &ActorId) -> ActorAttributes {
self.actors.get(actor).cloned().unwrap_or_default()
}
}
pub fn actor_attributes_from_value(value: Value) -> Result<ActorAttributesMap> {
let actors = value
.get("actors")
.and_then(Value::as_object)
.ok_or_else(|| invalid("missing actors object"))?;
let mut parsed = BTreeMap::new();
for (actor, attrs) in actors {
if !is_valid_principal_actor_id(actor) {
return Err(invalid(format!(
"actor key {actor} is not a valid actor id"
)));
}
parsed.insert(ActorId::new(actor), parse_attributes(actor, attrs)?);
}
Ok(ActorAttributesMap { actors: parsed })
}
fn parse_attributes(actor: &str, value: &Value) -> Result<ActorAttributes> {
let obj = value
.as_object()
.ok_or_else(|| invalid(format!("attributes for {actor} must be an object")))?;
let kind = match obj.get("kind") {
Some(Value::String(k)) => Some(normalize_token(actor, "kind", k)?),
None | Some(Value::Null) => {
return Err(invalid(format!(
"attributes for {actor} must declare exactly one kind"
)));
}
Some(_) => return Err(invalid(format!("kind for {actor} must be a string"))),
};
let mut roles = BTreeSet::new();
if let Some(value) = obj.get("roles") {
let array = value
.as_array()
.ok_or_else(|| invalid(format!("roles for {actor} must be an array")))?;
for role in array {
let role = role
.as_str()
.ok_or_else(|| invalid(format!("role for {actor} must be a string")))?;
roles.insert(normalize_token(actor, "role", role)?); }
}
Ok(ActorAttributes { kind, roles })
}
fn normalize_token(actor: &str, field: &str, token: &str) -> Result<String> {
let lowered = token.to_ascii_lowercase();
if lowered.is_empty()
|| !lowered
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(invalid(format!(
"{field} {token:?} for {actor} must be a lowercase kebab token ([a-z0-9-]+)"
)));
}
Ok(lowered)
}
fn invalid(reason: impl Into<String>) -> ShoreError {
ShoreError::WorkflowInputInvalid {
reason: format!("invalid actor attributes: {}", reason.into()),
}
}
pub const ACTOR_ATTRIBUTES_REL_PATH: &str = ".shore/actor-attributes.json";
pub const ACTOR_ATTRIBUTES_LOCAL_REL_PATH: &str = ".shore/actor-attributes.local.json";
const ACTOR_ATTRIBUTES_SCHEMA: &str = "shore.actor-attributes.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActorAttributesStageOutcome {
pub changed: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActorAttributesWriteRecord {
kind: String,
roles: Vec<String>,
comment: Option<String>,
}
impl ActorAttributesWriteRecord {
pub fn new(kind: String) -> Self {
Self {
kind,
roles: Vec::new(),
comment: None,
}
}
pub fn with_roles(mut self, roles: Vec<String>) -> Self {
self.roles = roles;
self
}
pub fn with_comment(mut self, comment: Option<String>) -> Self {
self.comment = comment;
self
}
}
pub fn stage_actor_attributes(
path: &Path,
actor: &ActorId,
attrs: &ActorAttributesWriteRecord,
) -> Result<ActorAttributesStageOutcome> {
if !is_valid_principal_actor_id(actor.as_str()) {
return Err(invalid(format!(
"actor key {} is not a valid actor id",
actor.as_str()
)));
}
let kind = normalize_token(actor.as_str(), "kind", &attrs.kind)?;
let mut roles_set = BTreeSet::new();
for role in &attrs.roles {
roles_set.insert(normalize_token(actor.as_str(), "role", role)?);
}
let mut root: Value = if path.exists() {
serde_json::from_slice(
&std::fs::read(path)
.map_err(|e| ShoreError::Message(format!("read {}: {e}", path.display())))?,
)?
} else {
let mut init = Map::new();
init.insert(
"schema".to_owned(),
Value::String(ACTOR_ATTRIBUTES_SCHEMA.to_owned()),
);
init.insert("actors".to_owned(), Value::Object(Map::new()));
Value::Object(init)
};
let root_obj = root
.as_object_mut()
.ok_or_else(|| invalid("attributes file is not an object"))?;
root_obj.insert(
"schema".to_owned(),
Value::String(ACTOR_ATTRIBUTES_SCHEMA.to_owned()),
);
let actors = root_obj
.entry("actors".to_owned())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| invalid("actors is not an object"))?;
let new_entry = actor_attributes_entry_value(&kind, &roles_set, attrs.comment.as_deref());
let changed = actors.get(actor.as_str()) != Some(&new_entry);
actors.insert(actor.as_str().to_owned(), new_entry);
actor_attributes_from_value(root.clone())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ShoreError::Message(format!("create {}: {e}", parent.display())))?;
}
let mut bytes = serde_json::to_vec_pretty(&root)?;
bytes.push(b'\n');
std::fs::write(path, &bytes)
.map_err(|e| ShoreError::Message(format!("write {}: {e}", path.display())))?;
Ok(ActorAttributesStageOutcome { changed })
}
fn actor_attributes_entry_value(
kind: &str,
roles: &BTreeSet<String>,
comment: Option<&str>,
) -> Value {
let mut obj = Map::new();
obj.insert("kind".to_owned(), Value::String(kind.to_owned()));
obj.insert(
"roles".to_owned(),
Value::Array(roles.iter().map(|r| Value::String(r.clone())).collect()),
);
if let Some(comment) = comment {
obj.insert("comment".to_owned(), Value::String(comment.to_owned()));
}
Value::Object(obj)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::ActorId;
const MAP: &str = r#"{
"schema": "shore.actor-attributes.v1",
"actors": {
"actor:agent:review-bot": { "kind": "reviewer-model", "roles": ["reviewer"] },
"actor:git-email:kevin@swiber.dev": { "kind": "human", "roles": ["reviewer", "author"], "comment": "me" }
}
}"#;
#[test]
fn resolves_declared_attributes() {
let map = actor_attributes_from_value(serde_json::from_str(MAP).unwrap()).unwrap();
let kevin = map.resolve(&ActorId::new("actor:git-email:kevin@swiber.dev"));
assert_eq!(kevin.kind(), Some("human"));
assert_eq!(
kevin.roles().iter().cloned().collect::<Vec<_>>(),
vec!["author", "reviewer"]
);
}
#[test]
fn absent_actor_resolves_unattributed_never_errors() {
let map = actor_attributes_from_value(serde_json::from_str(MAP).unwrap()).unwrap();
let unknown = map.resolve(&ActorId::new("actor:agent:nobody"));
assert_eq!(unknown.kind(), None);
assert!(unknown.roles().is_empty());
}
#[test]
fn local_override_replaces_per_actor() {
let committed = actor_attributes_from_value(serde_json::from_str(MAP).unwrap()).unwrap();
let local = actor_attributes_from_value(serde_json::json!({
"schema": "shore.actor-attributes.v1",
"actors": { "actor:agent:review-bot": { "kind": "agent", "roles": [] } }
}))
.unwrap();
let merged = committed.with_local_override(local);
assert_eq!(
merged
.resolve(&ActorId::new("actor:agent:review-bot"))
.kind(),
Some("agent")
);
assert_eq!(
merged
.resolve(&ActorId::new("actor:git-email:kevin@swiber.dev"))
.kind(),
Some("human")
);
}
#[test]
fn rejects_invalid_actor_key() {
let bad = serde_json::json!({
"schema": "shore.actor-attributes.v1",
"actors": { "not-an-actor": { "kind": "human" } }
});
assert!(actor_attributes_from_value(bad).is_err());
}
#[test]
fn rejects_non_kebab_kind_or_role() {
for value in [
serde_json::json!({"schema":"shore.actor-attributes.v1","actors":{"actor:agent:x":{"kind":"Reviewer_Model"}}}),
serde_json::json!({"schema":"shore.actor-attributes.v1","actors":{"actor:agent:x":{"kind":"agent","roles":["Has Space"]}}}),
] {
assert!(actor_attributes_from_value(value).is_err());
}
}
#[test]
fn rejects_missing_or_null_kind() {
for value in [
serde_json::json!({"schema":"shore.actor-attributes.v1","actors":{"actor:agent:x":{"roles":["reviewer"]}}}),
serde_json::json!({"schema":"shore.actor-attributes.v1","actors":{"actor:agent:x":{"kind":null}}}),
serde_json::json!({"schema":"shore.actor-attributes.v1","actors":{"actor:agent:x":{}}}),
] {
assert!(
actor_attributes_from_value(value.clone()).is_err(),
"missing/null kind must be rejected: {value}"
);
}
}
#[test]
fn git_name_actor_with_whitespace_is_a_valid_key() {
let value = serde_json::json!({
"schema": "shore.actor-attributes.v1",
"actors": { "actor:git-name:Kevin Swiber": { "kind": "human" } }
});
let map = actor_attributes_from_value(value).unwrap();
assert_eq!(
map.resolve(&ActorId::new("actor:git-name:Kevin Swiber"))
.kind(),
Some("human")
);
}
#[test]
fn declared_predicates_match_exactly() {
let map = actor_attributes_from_value(serde_json::json!({
"schema": "shore.actor-attributes.v1",
"actors": { "actor:git-email:kevin@swiber.dev": { "kind": "human", "roles": ["reviewer"] } }
}))
.unwrap();
let kevin = map.resolve(&ActorId::new("actor:git-email:kevin@swiber.dev"));
assert!(kevin.is_kind("human"));
assert!(!kevin.is_kind("agent"));
assert!(kevin.has_role("reviewer"));
assert!(!kevin.has_role("author"));
}
#[test]
fn hard_split_absent_agent_scheme_satisfies_no_kind_or_role_predicate() {
let map = ActorAttributesMap::default();
let agent = map.resolve(&ActorId::new("actor:agent:claude-code"));
assert_eq!(agent.kind(), None);
assert!(!agent.is_kind("agent"));
assert!(!agent.is_kind("human"));
assert!(!agent.has_role("reviewer"));
assert!(agent.roles().is_empty());
}
#[test]
fn hard_split_unrecognized_kind_round_trips_but_satisfies_no_predicate() {
let map = actor_attributes_from_value(serde_json::json!({
"schema": "shore.actor-attributes.v1",
"actors": { "actor:agent:future": { "kind": "quorum-service" } }
}))
.unwrap();
let future = map.resolve(&ActorId::new("actor:agent:future"));
assert_eq!(future.kind(), Some("quorum-service"));
assert!(
!future.is_kind("quorum-service"),
"unrecognized kind satisfies no kind= predicate"
);
assert!(!future.is_kind("human"));
assert!(!future.is_kind("service"));
}
#[test]
fn stage_actor_attributes_round_trips_through_the_reader() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".shore/actor-attributes.json");
let actor = ActorId::new("actor:git-email:kevin@swiber.dev");
let attrs = ActorAttributesWriteRecord::new("human".to_owned())
.with_roles(vec!["reviewer".to_owned(), "author".to_owned()])
.with_comment(Some("me".to_owned()));
let outcome = stage_actor_attributes(&path, &actor, &attrs).unwrap();
assert!(outcome.changed);
let map = ActorAttributesMap::from_attributes_file(&path).unwrap();
let resolved = map.resolve(&actor);
assert_eq!(resolved.kind(), Some("human"));
assert_eq!(
resolved.roles().iter().cloned().collect::<Vec<_>>(),
vec!["author", "reviewer"]
);
}
#[test]
fn stage_actor_attributes_normalizes_tokens_lowercase_kebab() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".shore/actor-attributes.json");
let actor = ActorId::new("actor:agent:review-bot");
let attrs = ActorAttributesWriteRecord::new("Reviewer-Model".to_owned()).with_roles(vec![
"Reviewer".to_owned(),
"reviewer".to_owned(),
"CI".to_owned(),
]);
stage_actor_attributes(&path, &actor, &attrs).unwrap();
let map = ActorAttributesMap::from_attributes_file(&path).unwrap();
let r = map.resolve(&actor);
assert_eq!(r.kind(), Some("reviewer-model"));
assert_eq!(
r.roles().iter().cloned().collect::<Vec<_>>(),
vec!["ci", "reviewer"]
);
}
#[test]
fn stage_actor_attributes_replaces_per_actor_and_is_byte_stable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".shore/actor-attributes.json");
let actor = ActorId::new("actor:git-email:kevin@swiber.dev");
let attrs = ActorAttributesWriteRecord::new("human".to_owned())
.with_roles(vec!["author".to_owned()]);
let first = stage_actor_attributes(&path, &actor, &attrs).unwrap();
let before = std::fs::read(&path).unwrap();
let second = stage_actor_attributes(&path, &actor, &attrs).unwrap();
let after = std::fs::read(&path).unwrap();
assert!(
first.changed && !second.changed,
"identical re-attest is a no-op"
);
assert_eq!(before, after, "re-attest leaves the file byte-identical");
assert!(before.ends_with(b"\n"));
let changed = stage_actor_attributes(
&path,
&actor,
&ActorAttributesWriteRecord::new("service".to_owned()),
)
.unwrap();
assert!(changed.changed);
assert_eq!(
ActorAttributesMap::from_attributes_file(&path)
.unwrap()
.resolve(&actor)
.kind(),
Some("service")
);
}
#[test]
fn stage_actor_attributes_preserves_other_actors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".shore/actor-attributes.json");
stage_actor_attributes(
&path,
&ActorId::new("actor:git-email:kevin@swiber.dev"),
&ActorAttributesWriteRecord::new("human".to_owned()),
)
.unwrap();
stage_actor_attributes(
&path,
&ActorId::new("actor:agent:review-bot"),
&ActorAttributesWriteRecord::new("reviewer-model".to_owned()),
)
.unwrap();
let map = ActorAttributesMap::from_attributes_file(&path).unwrap();
assert_eq!(
map.resolve(&ActorId::new("actor:git-email:kevin@swiber.dev"))
.kind(),
Some("human")
);
assert_eq!(
map.resolve(&ActorId::new("actor:agent:review-bot")).kind(),
Some("reviewer-model")
);
}
#[test]
fn stage_actor_attributes_refuses_when_an_existing_sibling_is_malformed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".shore/actor-attributes.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, br#"{"schema":"shore.actor-attributes.v1","actors":{"actor:agent:bad":{"roles":["reviewer"]}}}"#).unwrap();
let before = std::fs::read(&path).unwrap();
let result = stage_actor_attributes(
&path,
&ActorId::new("actor:git-email:kevin@swiber.dev"),
&ActorAttributesWriteRecord::new("human".to_owned()),
);
assert!(
result.is_err(),
"a malformed existing sibling must make the stage fail"
);
assert_eq!(
std::fs::read(&path).unwrap(),
before,
"a failed stage writes nothing"
);
}
#[test]
fn stage_actor_attributes_rejects_bad_key_and_bad_tokens() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".shore/actor-attributes.json");
assert!(
stage_actor_attributes(
&path,
&ActorId::new("not-an-actor"),
&ActorAttributesWriteRecord::new("human".to_owned())
)
.is_err()
);
assert!(
stage_actor_attributes(
&path,
&ActorId::new("actor:agent:x"),
&ActorAttributesWriteRecord::new("agent".to_owned())
.with_roles(vec!["Has Space".to_owned()])
)
.is_err()
);
assert!(!path.exists(), "a rejected attest writes nothing");
}
}