KeiSeiKit-1.0/_primitives/_rust/kei-agent-runtime/src/role.rs
Parfii-bot 84319efcb6 feat(convergence/p3): Role expression (extends/relaxes) + DNA identity
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>
2026-04-23 04:46:48 +08:00

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()))
}