Skip to main content

punkgo_kernel/runtime/
lifecycle.rs

1//! Actor lifecycle operations — freeze, unfreeze, terminate.
2//!
3//! Covers: PIP-001 §7 (agent conditional existence), §5/§6 (actor types),
4//! whitepaper §3 invariant 6 (governance auditable).
5//!
6//! PIP-001 §5/§6: Only humans create agents. Agents cannot manage other agents.
7//! §6 corollary: Orphan problem eliminated (human creators cannot be terminated).
8//!
9//! Lifecycle operations are triggered by submitting actions with:
10//!   target = "actor/{actor_id}"
11//!   action_type = Mutate
12//!   payload.op = "freeze" | "unfreeze" | "terminate"
13
14use crate::state::ActorStore;
15use punkgo_core::actor::{ActorRecord, ActorStatus, ActorType, LifecycleOp};
16use punkgo_core::errors::{KernelError, KernelResult};
17
18/// Parse a lifecycle operation from an action target + payload.
19///
20/// Returns None if this is not a lifecycle operation.
21pub fn parse_lifecycle_op(
22    target: &str,
23    payload: &serde_json::Value,
24) -> Option<(String, LifecycleOp)> {
25    // target must be "actor/{actor_id}"
26    let actor_id = target.strip_prefix("actor/")?;
27    if actor_id.is_empty() {
28        return None;
29    }
30
31    let op_str = payload.get("op")?.as_str()?;
32    let reason = payload
33        .get("reason")
34        .and_then(|v| v.as_str())
35        .map(|s| s.to_string());
36
37    let op = match op_str {
38        "freeze" => LifecycleOp::Freeze { reason },
39        "unfreeze" => LifecycleOp::Unfreeze,
40        "terminate" => LifecycleOp::Terminate { reason },
41        "update_energy_share" => {
42            let energy_share = payload.get("energy_share")?.as_f64()?;
43            LifecycleOp::UpdateEnergyShare { energy_share }
44        }
45        _ => return None,
46    };
47
48    Some((actor_id.to_string(), op))
49}
50
51/// Validate that the initiator is authorized to perform a lifecycle operation
52/// on the target actor (PIP-001 §5/§6 authorization rules).
53///
54/// PIP-001 §5/§6: Only Humans can create Agents, so only Humans can manage Agents.
55///
56/// | Initiator  | Target     | Allowed |
57/// |-----------|------------|---------|
58/// | human     | own agent  | yes (lineage check)  |
59/// | agent     | any        | no      |
60pub async fn validate_lifecycle_authorization(
61    initiator: &ActorRecord,
62    target: &ActorRecord,
63    _op: &LifecycleOp,
64) -> KernelResult<()> {
65    // Cannot perform lifecycle ops on humans
66    if target.actor_type == ActorType::Human {
67        return Err(KernelError::PolicyViolation(
68            "cannot perform lifecycle operations on human actors".to_string(),
69        ));
70    }
71
72    // PIP-001 §5: Agents cannot manage other agents.
73    if initiator.actor_type == ActorType::Agent {
74        return Err(KernelError::PolicyViolation(format!(
75            "agent {} cannot perform lifecycle operations — only humans can manage agents (PIP-001 §5)",
76            initiator.actor_id
77        )));
78    }
79
80    // Human initiator: target must be created by this human (lineage contains the human)
81    if initiator.actor_type == ActorType::Human {
82        if target.lineage.contains(&initiator.actor_id) {
83            return Ok(());
84        }
85        return Err(KernelError::PolicyViolation(format!(
86            "human {} cannot manage actor {} (not in lineage)",
87            initiator.actor_id, target.actor_id
88        )));
89    }
90
91    Err(KernelError::PolicyViolation(
92        "lifecycle authorization denied".to_string(),
93    ))
94}
95
96/// Execute a freeze operation: set target to frozen status, cascade to dependents.
97///
98/// Freezing suspends all state-changing actions.
99/// PIP-001 §5/§6: Agents have no children, but when a Human freezes,
100/// all agents they created (whose lineage contains the human_id) are also frozen.
101pub async fn execute_freeze(
102    actor_store: &ActorStore,
103    pool: &sqlx::SqlitePool,
104    target_id: &str,
105) -> KernelResult<Vec<String>> {
106    let mut tx = pool.begin().await?;
107    let mut frozen_ids = Vec::new();
108
109    // Freeze the target
110    actor_store
111        .set_status_in_tx(&mut tx, target_id, &ActorStatus::Frozen)
112        .await?;
113    frozen_ids.push(target_id.to_string());
114
115    // Cascade: freeze all descendants (actors whose lineage contains target_id)
116    let descendants = actor_store.list_descendants(target_id).await?;
117    for descendant in descendants {
118        actor_store
119            .set_status_in_tx(&mut tx, &descendant.actor_id, &ActorStatus::Frozen)
120            .await?;
121        frozen_ids.push(descendant.actor_id);
122    }
123
124    tx.commit().await?;
125    Ok(frozen_ids)
126}
127
128/// Execute an unfreeze operation: set target to active status.
129///
130/// Note: unfreeze does NOT cascade — each agent must be individually unfrozen.
131/// This is deliberate: the human must consciously decide to restore each agent.
132pub async fn execute_unfreeze(
133    actor_store: &ActorStore,
134    pool: &sqlx::SqlitePool,
135    target_id: &str,
136) -> KernelResult<()> {
137    let mut tx = pool.begin().await?;
138    actor_store
139        .set_status_in_tx(&mut tx, target_id, &ActorStatus::Active)
140        .await?;
141    tx.commit().await?;
142    Ok(())
143}
144
145/// Execute an update_energy_share operation: change the target's energy_share.
146pub async fn execute_update_energy_share(
147    actor_store: &ActorStore,
148    pool: &sqlx::SqlitePool,
149    target_id: &str,
150    energy_share: f64,
151) -> KernelResult<()> {
152    let mut tx = pool.begin().await?;
153    actor_store
154        .update_energy_share_in_tx(&mut tx, target_id, energy_share)
155        .await?;
156    tx.commit().await?;
157    Ok(())
158}
159
160/// Check existence conditions for an actor (PIP-001 §7).
161///
162/// Four conditions that must hold for an actor to exist:
163/// 1. Energy >= 0 — checked naturally by reserve/settle
164/// 2. Creator active — the human creator must be active (PIP-001 §7)
165/// 3. Writable boundary non-empty — checked by Phase 3 boundary enforcement
166/// 4. Not frozen — checked by Phase 1 frozen status
167///
168/// This function checks condition 2 (creator activity).
169/// PIP-001 §5/§6: lineage is always single-element \[human_creator_id\],
170/// so this is effectively "is my creator active?"
171pub async fn check_lineage_active(
172    actor_store: &ActorStore,
173    lineage: &[String],
174) -> KernelResult<()> {
175    for ancestor_id in lineage {
176        let is_active = actor_store.is_active(ancestor_id).await?;
177        if !is_active {
178            return Err(KernelError::PolicyViolation(format!(
179                "lineage ancestor {} is not active (PIP-001 §7: delegator absence)",
180                ancestor_id
181            )));
182        }
183    }
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use serde_json::json;
191
192    #[test]
193    fn parse_update_energy_share_op() {
194        let (id, op) = parse_lifecycle_op(
195            "actor/agent-1",
196            &json!({"op": "update_energy_share", "energy_share": 42.5}),
197        )
198        .expect("should parse");
199        assert_eq!(id, "agent-1");
200        assert!(
201            matches!(op, LifecycleOp::UpdateEnergyShare { energy_share } if (energy_share - 42.5).abs() < f64::EPSILON)
202        );
203    }
204
205    #[test]
206    fn parse_update_energy_share_missing_value() {
207        let result = parse_lifecycle_op("actor/agent-1", &json!({"op": "update_energy_share"}));
208        assert!(
209            result.is_none(),
210            "missing energy_share value should return None"
211        );
212    }
213
214    #[test]
215    fn parse_freeze_op() {
216        let (id, op) =
217            parse_lifecycle_op("actor/agent-1", &json!({"op": "freeze", "reason": "test"}))
218                .expect("should parse");
219        assert_eq!(id, "agent-1");
220        assert!(matches!(op, LifecycleOp::Freeze { reason: Some(r) } if r == "test"));
221    }
222
223    #[test]
224    fn parse_unfreeze_op() {
225        let (id, op) =
226            parse_lifecycle_op("actor/agent-1", &json!({"op": "unfreeze"})).expect("should parse");
227        assert_eq!(id, "agent-1");
228        assert!(matches!(op, LifecycleOp::Unfreeze));
229    }
230
231    #[test]
232    fn parse_non_lifecycle_target() {
233        let result = parse_lifecycle_op("workspace/a", &json!({"op": "freeze"}));
234        assert!(result.is_none(), "non-actor target should return None");
235    }
236
237    #[test]
238    fn parse_unknown_op() {
239        let result = parse_lifecycle_op("actor/agent-1", &json!({"op": "destroy"}));
240        assert!(result.is_none(), "unknown op should return None");
241    }
242}