punkgo_core/actor.rs
1//! Actor types and identity derivation.
2//!
3//! Covers: PIP-001 §5 (actor type dichotomy), §7 (agent conditional existence),
4//! §10 (root wildcard boundary).
5
6use serde::{Deserialize, Serialize};
7
8// ---------------------------------------------------------------------------
9// Enums
10// ---------------------------------------------------------------------------
11
12/// PIP-001 §5: Two immutable actor types, exhaustive and mutually exclusive.
13/// - Human: direct human representative, created at kernel init or via identity verification.
14/// - Agent: delegated executor, created **only by human** via `create` action (PIP-001 §5/§6).
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum ActorType {
18 Human,
19 Agent,
20}
21
22impl ActorType {
23 pub fn as_str(&self) -> &'static str {
24 match self {
25 Self::Human => "human",
26 Self::Agent => "agent",
27 }
28 }
29
30 pub fn parse(s: &str) -> Option<Self> {
31 match s {
32 "human" => Some(Self::Human),
33 "agent" => Some(Self::Agent),
34 _ => None,
35 }
36 }
37}
38
39/// Actor lifecycle status.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum ActorStatus {
43 Active,
44 Frozen,
45}
46
47impl ActorStatus {
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 Self::Active => "active",
51 Self::Frozen => "frozen",
52 }
53 }
54
55 pub fn parse(s: &str) -> Option<Self> {
56 match s {
57 "active" => Some(Self::Active),
58 "frozen" => Some(Self::Frozen),
59 _ => None,
60 }
61 }
62}
63
64// ---------------------------------------------------------------------------
65// Structs
66// ---------------------------------------------------------------------------
67
68/// PIP-001 §8: Writability declaration — a glob pattern + allowed action types.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct WritableTarget {
71 pub target: String,
72 pub actions: Vec<String>,
73}
74
75/// Full actor record as persisted in the `actors` table.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ActorRecord {
78 pub actor_id: String,
79 pub actor_type: ActorType,
80 pub creator_id: Option<String>,
81 pub lineage: Vec<String>,
82 pub purpose: Option<String>,
83 pub status: ActorStatus,
84 pub writable_targets: Vec<WritableTarget>,
85 pub energy_share: f64,
86 pub reduction_policy: String,
87 pub created_at: String,
88 pub updated_at: String,
89}
90
91/// Specification for creating a new actor (used in submit pipeline).
92#[derive(Debug, Clone)]
93pub struct CreateActorSpec {
94 pub actor_id: String,
95 pub actor_type: ActorType,
96 pub creator_id: String,
97 pub lineage: Vec<String>,
98 pub purpose: Option<String>,
99 pub writable_targets: Vec<WritableTarget>,
100 pub energy_balance: i64,
101 pub energy_share: f64,
102 pub reduction_policy: String,
103}
104
105// ---------------------------------------------------------------------------
106// Lifecycle operations (PIP-001 §5/§6/§7)
107// ---------------------------------------------------------------------------
108
109/// Phase 4a: Lifecycle operations that can be performed on actors.
110#[derive(Debug, Clone, PartialEq)]
111pub enum LifecycleOp {
112 /// Freeze an actor — suspends all state-changing actions.
113 Freeze { reason: Option<String> },
114 /// Unfreeze — restores an actor to active.
115 Unfreeze,
116 /// Terminate — permanently removes an agent (creates orphans).
117 Terminate { reason: Option<String> },
118 /// Update an actor's energy_share for tick-based distribution.
119 UpdateEnergyShare { energy_share: f64 },
120}
121
122// ---------------------------------------------------------------------------
123// Identity derivation
124// ---------------------------------------------------------------------------
125
126/// Derive an agent ID that encodes the creation relationship.
127///
128/// Format: `{creator_id}/{sanitized_purpose}/{seq}`
129/// Example: `root/doc-organizer/1`, `root/doc-organizer/1/sub-worker/1`
130pub fn derive_agent_id(creator_id: &str, purpose: &str, seq: i64) -> String {
131 let sanitized = sanitize_purpose(purpose);
132 format!("{creator_id}/{sanitized}/{seq}")
133}
134
135/// Sanitize a purpose string for use in an actor ID.
136/// Replace non-alphanumeric/non-hyphen characters with hyphens, lowercase.
137fn sanitize_purpose(purpose: &str) -> String {
138 let s: String = purpose
139 .chars()
140 .map(|c| {
141 if c.is_ascii_alphanumeric() || c == '-' {
142 c.to_ascii_lowercase()
143 } else {
144 '-'
145 }
146 })
147 .collect();
148 // Collapse consecutive hyphens
149 let mut result = String::with_capacity(s.len());
150 let mut prev_hyphen = false;
151 for c in s.chars() {
152 if c == '-' {
153 if !prev_hyphen {
154 result.push(c);
155 }
156 prev_hyphen = true;
157 } else {
158 result.push(c);
159 prev_hyphen = false;
160 }
161 }
162 result.trim_matches('-').to_string()
163}
164
165// ---------------------------------------------------------------------------
166// Lineage construction (PIP-001 §7)
167// ---------------------------------------------------------------------------
168
169/// Build a creation lineage for a new actor (PIP-001 §7).
170///
171/// Since only Humans can create Agents (PIP-001 §5/§6), lineage is always
172/// a single element: `\[human_creator_id\]`.
173///
174/// The `creator_type` and `creator_lineage` parameters are kept for
175/// backward compatibility but the function enforces Human-only creation.
176pub fn build_lineage(
177 creator_type: &ActorType,
178 creator_id: &str,
179 _creator_lineage: &[String],
180) -> Vec<String> {
181 // PIP-001 §5/§6: Only Humans can create Agents.
182 // If creator is Human -> lineage = [human_id] (the only valid case).
183 // If creator is Agent -> still returns [creator_id] but kernel.rs should
184 // reject this before we get here.
185 match creator_type {
186 ActorType::Human => vec![creator_id.to_string()],
187 ActorType::Agent => {
188 // This path should never be reached after PIP-001 §5 enforcement in kernel.rs.
189 // Return creator_id as fallback for safety.
190 vec![creator_id.to_string()]
191 }
192 }
193}
194
195// ---------------------------------------------------------------------------
196// Tests
197// ---------------------------------------------------------------------------
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn derive_agent_id_basic() {
205 assert_eq!(
206 derive_agent_id("root", "doc-organizer", 1),
207 "root/doc-organizer/1"
208 );
209 }
210
211 #[test]
212 fn derive_agent_id_second_agent() {
213 // PIP-001 §5/§6: Only humans create agents, so creator_id is always a human id.
214 // This tests creating a second agent by the same human.
215 assert_eq!(
216 derive_agent_id("root", "tiktok-downloader", 1),
217 "root/tiktok-downloader/1"
218 );
219 }
220
221 #[test]
222 fn derive_agent_id_sanitizes_spaces() {
223 assert_eq!(
224 derive_agent_id("root", "My Cool Agent", 1),
225 "root/my-cool-agent/1"
226 );
227 }
228
229 #[test]
230 fn build_lineage_human_creator() {
231 let lineage = build_lineage(&ActorType::Human, "root", &[]);
232 assert_eq!(lineage, vec!["root"]);
233 }
234
235 #[test]
236 fn build_lineage_agent_creator_fallback() {
237 // PIP-001 §5: Agent-creates-Agent is rejected at kernel level.
238 // build_lineage still returns a safe fallback if called with Agent creator.
239 let creator_lineage = vec!["root".to_string()];
240 let lineage = build_lineage(&ActorType::Agent, "root/worker/1", &creator_lineage);
241 // Fallback: returns [creator_id] (not the old recursive chain)
242 assert_eq!(lineage, vec!["root/worker/1"]);
243 }
244
245 #[test]
246 fn actor_type_roundtrip() {
247 assert_eq!(ActorType::parse("human"), Some(ActorType::Human));
248 assert_eq!(ActorType::parse("agent"), Some(ActorType::Agent));
249 assert_eq!(ActorType::parse("unknown"), None);
250 }
251
252 #[test]
253 fn actor_status_roundtrip() {
254 assert_eq!(ActorStatus::parse("active"), Some(ActorStatus::Active));
255 assert_eq!(ActorStatus::parse("frozen"), Some(ActorStatus::Frozen));
256 }
257}