Layer E + G. Role TOML gains extends/relaxes for parent-role composition; agent spawn gets self-describing DNA identity alongside UUID. Role expression: - _roles/*.toml gain optional `extends = "<parent>"` + `relaxes = [...]` - compose.rs + verify.rs delegate to new role::resolve_role() with recursive extends-chain resolution + cycle detection - explorer.toml: 28→18 LOC (extends read-only) - edit-shared.toml: 31→23 LOC (extends edit-local, relaxes scope::files-whitelist for task-param override) DNA identity: - new dna.rs (159 LOC) — compose/render/parse round-trip - AgentInvocation carries dna field (prepare.rs) - Format: <role>::<caps-bitmap>::<sha4-scope>::<sha4-body>-<hex4-nonce> - ≤ 80 chars total, greppable, parseable - 11 capability codes in CAP_CODES table: NG, FW, FD, CP, CG, TG, ND, RF, SG, DT, BA kei-ledger schema v2: - ADD COLUMN dna TEXT + prefix index - `kei-ledger fork --dna <string>` optional flag - AgentRow.dna: Option<String> - Backward compat: schema migration detects + applies on open Docs: AGENT-SUBSTRATE-SCHEMA.md Layer E + Layer G sections + CAP_CODES table. New deps: sha2 (workspace), rand 0.8. Tests: kei-agent-runtime 50 (was 41, +9: 4 role + 5 DNA), kei-ledger 10 (was 9, +1 DNA roundtrip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 lines
3.3 KiB
Rust
95 lines
3.3 KiB
Rust
//! Role expression resolver — Layer E.
|
|
//!
|
|
//! Parses `_roles/<name>.toml` and resolves `extends` chains with `relaxes`
|
|
//! subtraction, emitting a flat `ResolvedRole` for downstream consumers
|
|
//! (`compose`, `prepare`, `verify`, `dna`).
|
|
//!
|
|
//! Semantics:
|
|
//! - `extends` — optional parent role slug; loaded recursively.
|
|
//! - `required` (local) — merged on top of parent's resolved required.
|
|
//! - `relaxes` — slugs in parent's resolved required to DROP. Warn on stderr
|
|
//! if a relaxed cap wasn't present in the inherited set.
|
|
//! - Cycle detection — visited set passed down the recursion; an error with
|
|
//! a clear path is returned when a cycle is found.
|
|
//!
|
|
//! Constructor Pattern: one cube = one responsibility (role expression only).
|
|
//! No I/O beyond `std::fs::read_to_string`. Dispatched from `compose::load_role`
|
|
//! and `verify::load_role_capabilities` so both share the same semantics.
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use serde::Deserialize;
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
|
|
/// Flattened role ready for downstream composition.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ResolvedRole {
|
|
/// Ordered capability names after `extends` merge + `relaxes` subtraction.
|
|
pub required: Vec<String>,
|
|
}
|
|
|
|
/// Deserialized role file (raw shape, pre-resolution).
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct RoleFileRaw {
|
|
#[serde(default)]
|
|
pub capabilities: RoleCapsRaw,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct RoleCapsRaw {
|
|
#[serde(default)]
|
|
pub extends: Option<String>,
|
|
#[serde(default)]
|
|
pub required: Vec<String>,
|
|
#[serde(default)]
|
|
pub relaxes: Vec<String>,
|
|
}
|
|
|
|
/// Resolve a role by slug; read role file, walk `extends`, apply `relaxes`.
|
|
pub fn resolve_role(kit_root: &Path, role: &str) -> Result<ResolvedRole> {
|
|
let mut visited: HashSet<String> = HashSet::new();
|
|
resolve_inner(kit_root, role, &mut visited)
|
|
}
|
|
|
|
fn resolve_inner(
|
|
kit_root: &Path,
|
|
role: &str,
|
|
visited: &mut HashSet<String>,
|
|
) -> Result<ResolvedRole> {
|
|
if !visited.insert(role.to_string()) {
|
|
return Err(anyhow!(
|
|
"cycle detected in role `extends` chain at `{role}` (path: {:?})",
|
|
visited
|
|
));
|
|
}
|
|
let raw = read_role_file(kit_root, role)?;
|
|
let mut merged = match raw.capabilities.extends.as_deref() {
|
|
Some(parent) => resolve_inner(kit_root, parent, visited)?.required,
|
|
None => Vec::new(),
|
|
};
|
|
for cap in &raw.capabilities.required {
|
|
if !merged.iter().any(|c| c == cap) {
|
|
merged.push(cap.clone());
|
|
}
|
|
}
|
|
for dropped in &raw.capabilities.relaxes {
|
|
let before = merged.len();
|
|
merged.retain(|c| c != dropped);
|
|
if merged.len() == before {
|
|
eprintln!(
|
|
"[kei-agent-runtime] role `{role}` relaxes `{dropped}` \
|
|
but it was not in the inherited capability set — no-op"
|
|
);
|
|
}
|
|
}
|
|
visited.remove(role);
|
|
Ok(ResolvedRole { required: merged })
|
|
}
|
|
|
|
fn read_role_file(kit_root: &Path, role: &str) -> Result<RoleFileRaw> {
|
|
let path = kit_root.join("_roles").join(format!("{role}.toml"));
|
|
let text = std::fs::read_to_string(&path)
|
|
.with_context(|| format!("read role file {}", path.display()))?;
|
|
toml::from_str::<RoleFileRaw>(&text)
|
|
.with_context(|| format!("parse role TOML {}", path.display()))
|
|
}
|