Skip to main content

punkgo_kernel/runtime/
kernel.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use tracing::{info, warn};
6use uuid::Uuid;
7
8use crate::audit::AuditLog;
9use punkgo_core::action::{Action, ActionType, payload_hash_hex, quote_cost};
10use punkgo_core::actor::{
11    ActorType, CreateActorSpec, WritableTarget, build_lineage, derive_agent_id,
12};
13use punkgo_core::boundary::{check_writable_boundary, validate_child_targets};
14use punkgo_core::consent::{self, AuthorizationMode, CheckpointLevel, EnvelopeSpec, HoldRule};
15use punkgo_core::errors::{KernelError, KernelResult};
16use punkgo_core::policy::{check_read_access, validate_action};
17
18use super::lifecycle;
19use crate::state::{
20    ActorStore, EnergyLedger, EnergyReservation, EnvelopeStore, EventLog, EventRecord,
21    NewHoldRequest, StateStore,
22};
23use punkgo_core::protocol::{RequestEnvelope, RequestType, ResponseEnvelope};
24use punkgo_core::stellar::{StellarConfig, load_stellar_config};
25
26/// Configuration for bootstrapping the kernel.
27#[derive(Debug, Clone)]
28pub struct KernelConfig {
29    pub state_dir: PathBuf,
30    pub ipc_endpoint: String,
31}
32
33impl Default for KernelConfig {
34    fn default() -> Self {
35        let state_dir = std::env::var("PUNKGO_STATE_DIR")
36            .map(PathBuf::from)
37            .unwrap_or_else(|_| default_state_dir());
38        let ipc_endpoint =
39            std::env::var("PUNKGO_IPC_ENDPOINT").unwrap_or_else(|_| default_ipc_endpoint());
40        Self {
41            state_dir,
42            ipc_endpoint,
43        }
44    }
45}
46
47/// Default state directory: ~/.punkgo/state
48///
49/// Falls back to `./state` if home directory cannot be determined.
50/// Default IPC endpoint — unique per PID.
51///
52/// Each daemon instance gets its own socket/pipe keyed by PID, enabling
53/// clean recovery after crashes (stale sockets are cleaned up by new daemons).
54///
55/// - Windows: `\\.\pipe\punkgo-kernel-{pid}` (named pipe)
56/// - Unix: `{state_dir}/daemon-{pid}.sock` (Unix domain socket)
57fn default_ipc_endpoint() -> String {
58    let pid = std::process::id();
59    if cfg!(windows) {
60        format!(r"\\.\pipe\punkgo-kernel-{pid}")
61    } else {
62        let state_dir = std::env::var("PUNKGO_STATE_DIR")
63            .map(std::path::PathBuf::from)
64            .unwrap_or_else(|_| default_state_dir());
65        state_dir
66            .join(format!("daemon-{pid}.sock"))
67            .to_string_lossy()
68            .into_owned()
69    }
70}
71
72fn default_state_dir() -> PathBuf {
73    if let Some(home) = home_dir() {
74        return home.join(".punkgo").join("state");
75    }
76    PathBuf::from("state")
77}
78
79fn home_dir() -> Option<PathBuf> {
80    // Unix / WSL
81    if let Some(home) = std::env::var_os("HOME") {
82        return Some(PathBuf::from(home));
83    }
84    // Windows primary
85    if let Some(profile) = std::env::var_os("USERPROFILE") {
86        return Some(PathBuf::from(profile));
87    }
88    // Windows fallback
89    let drive = std::env::var_os("HOMEDRIVE")?;
90    let path = std::env::var_os("HOMEPATH")?;
91    let mut p = PathBuf::from(drive);
92    p.push(path);
93    Some(p)
94}
95
96/// Cryptographic receipt returned after a successful action submission.
97///
98/// Contains the event ID, log index, event hash, and energy costs.
99/// This is the caller's proof that their action was committed to the
100/// append-only history.
101#[derive(Debug, Clone, Serialize)]
102pub struct SubmitReceipt {
103    pub event_id: String,
104    pub log_index: i64,
105    pub event_hash: String,
106    pub reserved_cost: i64,
107    pub settled_cost: i64,
108    pub artifact_hash: Option<String>,
109}
110
111#[derive(Debug, Deserialize)]
112struct ReadQuery {
113    kind: String,
114    actor_id: Option<String>,
115    /// PIP-001 §11: hold_id for hold_info queries.
116    #[serde(default)]
117    hold_id: Option<String>,
118    limit: Option<i64>,
119    /// For audit_inclusion_proof: the event's log_index to prove.
120    log_index: Option<i64>,
121    /// For audit_inclusion_proof / audit_consistency_proof: the tree size to prove against.
122    tree_size: Option<i64>,
123    /// For audit_consistency_proof: the older tree size.
124    old_size: Option<i64>,
125    /// Pagination: only return events with log_index < this value.
126    /// Used for backward pagination (fetching older events).
127    #[serde(default)]
128    before_index: Option<i64>,
129    /// Pagination: only return events with log_index > this value.
130    /// Used for forward pagination (fetching newer events).
131    #[serde(default)]
132    after_index: Option<i64>,
133    /// Identity of the requester. Used by visibility boundary checks.
134    /// Currently optional — when absent, all reads are permitted (single-user).
135    /// When multi-user collaboration is introduced, this becomes required.
136    #[serde(default)]
137    requester_id: Option<String>,
138}
139
140/// The PunkGo kernel — single-writer append-only event system.
141///
142/// The kernel is the **committer** (whitepaper §2): a structural role that
143/// provides a single linearization point for actions. It is not a judge.
144///
145/// Use [`Kernel::bootstrap`] to initialize from a state directory, then
146/// [`Kernel::handle_request`] to process IPC requests.
147pub struct Kernel {
148    state_store: StateStore,
149    energy_ledger: EnergyLedger,
150    event_log: EventLog,
151    audit_log: AuditLog,
152    actor_store: ActorStore,
153    envelope_store: EnvelopeStore,
154    stellar_config: StellarConfig,
155    signing_key: crate::signing::SigningKey,
156}
157
158impl Kernel {
159    /// Initialize the kernel from a state directory.
160    ///
161    /// Creates the SQLite database, loads stellar config, and prepares the root actor.
162    pub async fn bootstrap(config: &KernelConfig) -> KernelResult<Self> {
163        let state_store = StateStore::bootstrap(&config.state_dir).await?;
164        let energy_ledger = EnergyLedger::new(state_store.pool());
165        let event_log = EventLog::new(state_store.pool());
166        // Load or generate Ed25519 signing key for checkpoint authentication.
167        let signing_key_path = config.state_dir.join("signing_key");
168        let signing_key = crate::signing::SigningKey::load_or_generate(&signing_key_path)
169            .map_err(|e| KernelError::Audit(format!("signing key: {e}")))?;
170        tracing::info!(pubkey = %signing_key.public_key_hex(), "checkpoint signing key loaded");
171        let audit_log = AuditLog::new(
172            state_store.pool(),
173            "punkgo/kernel",
174            Some(signing_key.clone()),
175        );
176        let actor_store = ActorStore::new(state_store.pool());
177        let envelope_store = EnvelopeStore::new(state_store.pool());
178
179        // Phase 2: Load stellar configuration (PIP-001 §1).
180        let stellar_config_path = config.state_dir.join("stellar.toml");
181        let stellar_config = load_stellar_config(&stellar_config_path)?;
182        info!(
183            energy_per_tick = stellar_config.effective_energy_per_tick(),
184            int8_tops = stellar_config.int8_tops,
185            tick_interval_ms = stellar_config.tick_interval_ms,
186            "stellar configuration loaded"
187        );
188
189        Ok(Self {
190            state_store,
191            energy_ledger,
192            event_log,
193            audit_log,
194            actor_store,
195            envelope_store,
196            stellar_config,
197            signing_key,
198        })
199    }
200
201    /// Returns a reference to the stellar configuration for external use
202    /// (e.g., energy producer startup).
203    pub fn stellar_config(&self) -> &StellarConfig {
204        &self.stellar_config
205    }
206
207    /// Returns a clone of the energy ledger for external use (e.g., energy producer).
208    pub fn energy_ledger(&self) -> &EnergyLedger {
209        &self.energy_ledger
210    }
211
212    /// Returns a clone of the actor store for external use (e.g., energy producer).
213    pub fn actor_store(&self) -> &ActorStore {
214        &self.actor_store
215    }
216
217    /// Returns a reference to the envelope store for external use.
218    pub fn envelope_store(&self) -> &EnvelopeStore {
219        &self.envelope_store
220    }
221
222    /// Returns the SQLite pool for external use (e.g., energy producer transaction).
223    pub fn pool(&self) -> sqlx::SqlitePool {
224        self.state_store.pool()
225    }
226
227    /// Handle an incoming IPC request — dispatches to quote, submit, or read.
228    pub async fn handle_request(&self, req: RequestEnvelope) -> ResponseEnvelope {
229        let request_id = req.request_id.clone();
230        info!(
231            request_id = %request_id,
232            request_type = ?req.request_type,
233            "received request"
234        );
235        match self.dispatch(req).await {
236            Ok(payload) => {
237                info!(request_id = %request_id, "request completed");
238                ResponseEnvelope::ok(request_id, payload)
239            }
240            Err(err) => {
241                warn!(error = %err, "request failed");
242                ResponseEnvelope::err_structured(request_id, &err)
243            }
244        }
245    }
246
247    async fn dispatch(&self, req: RequestEnvelope) -> KernelResult<Value> {
248        match req.request_type {
249            RequestType::Quote => {
250                let action: Action = serde_json::from_value(req.payload)?;
251                validate_action(&action)?;
252                let cost = quote_cost(&action);
253                Ok(json!({ "cost": cost }))
254            }
255            RequestType::Submit => {
256                let action: Action = serde_json::from_value(req.payload)?;
257                let receipt = self.submit_action(action).await?;
258                Ok(serde_json::to_value(receipt)?)
259            }
260            RequestType::Read => {
261                let query: ReadQuery = serde_json::from_value(req.payload)?;
262                self.read_query(query).await
263            }
264        }
265    }
266
267    async fn submit_action(&self, action: Action) -> KernelResult<SubmitReceipt> {
268        // Step 1: VALIDATE — policy + actor existence + frozen check
269        validate_action(&action)?;
270        if !self.state_store.actor_exists(&action.actor_id).await? {
271            return Err(KernelError::ActorNotFound(action.actor_id.clone()));
272        }
273
274        // Phase 1: check frozen status via actors table.
275        // Phase 3: check writable boundary (PIP-001 §8/§9).
276        // Phase 4a: check lineage activity (PIP-001 §7) + lifecycle ops.
277        if let Some(actor) = self.actor_store.get(&action.actor_id).await? {
278            if actor.status == punkgo_core::actor::ActorStatus::Frozen
279                && action.action_type.is_state_changing()
280            {
281                return Err(KernelError::ActorFrozen(format!(
282                    "actor {} is frozen and cannot perform state-changing actions",
283                    action.actor_id
284                )));
285            }
286            // Phase 3: Boundary enforcement — check writable_targets.
287            check_writable_boundary(&actor, &action.target, &action.action_type)?;
288
289            // Phase 4a: Check lineage activity for agents (PIP-001 §7).
290            // Agents need their entire creation chain to be active.
291            if actor.actor_type == punkgo_core::actor::ActorType::Agent
292                && action.action_type.is_state_changing()
293            {
294                lifecycle::check_lineage_active(&self.actor_store, &actor.lineage).await?;
295            }
296
297            // Phase 4b: Authorization check (PIP-001 §5/§6/§11).
298            // Agents performing state-changing actions need an active envelope.
299            if action.action_type.is_state_changing() {
300                let envelope = self
301                    .envelope_store
302                    .get_active_for_actor(&action.actor_id)
303                    .await?;
304
305                // Lazy expiry check (PIP-001 §11 checkpoint): if envelope exists but is time-expired, mark it.
306                let envelope = if let Some(env) = envelope {
307                    if consent::is_envelope_expired(&env, now_millis_u64()) {
308                        self.envelope_store
309                            .set_status(&env.envelope_id, &consent::EnvelopeStatus::Expired)
310                            .await?;
311                        None
312                    } else {
313                        Some(env)
314                    }
315                } else {
316                    None
317                };
318
319                let auth_mode = consent::check_authorization(&actor, envelope.as_ref())?;
320
321                // For ManOnTheLoop, also verify envelope covers this specific action.
322                if auth_mode == AuthorizationMode::ManOnTheLoop
323                    && let Some(ref env) = envelope
324                {
325                    consent::check_envelope_covers(
326                        env,
327                        &action.target,
328                        action.action_type.as_str(),
329                    )?;
330
331                    // PIP-001 §11e: Lazy-expire timed-out holds before checking new triggers.
332                    if !env.hold_on.is_empty() {
333                        if let Some(timeout_secs) = env.hold_timeout_secs {
334                            if timeout_secs > 0 {
335                                self.expire_timed_out_holds(&env.envelope_id, timeout_secs)
336                                    .await?;
337                            }
338                        }
339                    }
340
341                    // PIP-001 §11b: Check hold_on rules — if triggered, create a hold_request
342                    // event and reserve energy before execution.
343                    // Skip the hold check if this action was already approved by a Human
344                    // (re-execution after approve; PIP-001 §11d).
345                    let hold_approved = action
346                        .payload
347                        .get("_hold_approved")
348                        .and_then(|v| v.as_bool())
349                        .unwrap_or(false);
350                    if !hold_approved
351                        && consent::check_hold_trigger(
352                            &env.hold_on,
353                            &action.target,
354                            action.action_type.as_str(),
355                        )
356                    {
357                        let hold_id = Uuid::new_v4().to_string();
358
359                        // PIP-001 §11c: Commit = reserve energy (gasLimit model).
360                        // Reserves energy upfront so agent can't overspend while hold is pending.
361                        let reserved_cost = quote_cost(&action) as i64;
362
363                        let hold_payload = json!({
364                            "hold_id": &hold_id,
365                            "agent_id": &action.actor_id,
366                            "trigger": {
367                                "target": &action.target,
368                                "action_type": action.action_type.as_str()
369                            },
370                            "pending_action": {
371                                "target": &action.target,
372                                "action_type": action.action_type.as_str(),
373                                "payload": &action.payload
374                            },
375                            "reserved_cost": reserved_cost,
376                            "triggered_at": now_millis_string()
377                        });
378
379                        // Write hold_request event into history (PIP-001 §11b, whitepaper §3 inv 6).
380                        let hold_action = Action {
381                            actor_id: action.actor_id.clone(),
382                            action_type: ActionType::Mutate,
383                            target: format!("ledger/hold/{hold_id}"),
384                            payload: hold_payload.clone(),
385                            timestamp: None,
386                        };
387                        let mut hold_event = EventRecord {
388                            id: Uuid::new_v4().to_string(),
389                            log_index: 0,
390                            event_hash: String::new(),
391                            actor_id: action.actor_id.clone(),
392                            action_type: "hold_request".to_string(),
393                            target: format!("ledger/hold/{hold_id}"),
394                            payload: hold_payload.clone(),
395                            payload_hash: payload_hash_hex(&hold_action)?,
396                            artifact_hash: None,
397                            reserved_energy: reserved_cost,
398                            settled_energy: 0,
399                            timestamp: now_millis_string(),
400                        };
401
402                        // Atomic: reserve + hold_request + event in one transaction (PIP-001 §11b/§11c).
403                        let pool = self.state_store.pool();
404                        let mut tx = pool.begin().await?;
405                        if reserved_cost > 0 {
406                            self.energy_ledger
407                                .reserve_in_tx(&mut tx, &action.actor_id, reserved_cost)
408                                .await?;
409                        }
410                        self.event_log
411                            .append_in_tx(&mut tx, &mut hold_event)
412                            .await?;
413                        self.envelope_store
414                            .create_hold_request_in_tx(
415                                &mut tx,
416                                &NewHoldRequest {
417                                    hold_id: &hold_id,
418                                    envelope_id: &env.envelope_id,
419                                    agent_id: &action.actor_id,
420                                    trigger_target: &action.target,
421                                    trigger_action: action.action_type.as_str(),
422                                    pending_payload: &json!({
423                                        "target": &action.target,
424                                        "action_type": action.action_type.as_str(),
425                                        "payload": &action.payload,
426                                        "reserved_cost": reserved_cost
427                                    }),
428                                },
429                            )
430                            .await?;
431                        // Envelope stays Active — agent can continue submitting (PIP-001 §11b).
432
433                        // Audit trail — atomic with event (whitepaper §3 invariant 5).
434                        let log_index = hold_event.log_index as u64;
435                        self.audit_log
436                            .append_leaf_in_tx(&mut tx, log_index, &hold_event.event_hash)
437                            .await
438                            .map_err(|e| KernelError::Audit(e.to_string()))?;
439                        // Checkpoint generated lazily on read, not here.
440                        tx.commit().await?;
441
442                        return Err(KernelError::HoldTriggered {
443                            hold_id,
444                            agent_id: action.actor_id.clone(),
445                        });
446                    }
447                }
448            }
449        }
450
451        // PIP-001 §11d: Check for hold_response (approve/reject) operations.
452        // Pattern: mutate "ledger/hold/<hold_id>" with payload { decision, instruction? }
453        let hold_response = if matches!(action.action_type, ActionType::Mutate) {
454            parse_hold_response(&action.target, &action.payload)
455        } else {
456            None
457        };
458
459        // Validate and execute hold_response before entering the normal pipeline.
460        if let Some((ref hold_id, ref decision, ref instruction)) = hold_response {
461            return self
462                .execute_hold_response(&action, hold_id, decision, instruction.as_deref())
463                .await;
464        }
465
466        // Phase 4a: Check for lifecycle operations (freeze/unfreeze/terminate).
467        let lifecycle_op = if matches!(action.action_type, ActionType::Mutate) {
468            lifecycle::parse_lifecycle_op(&action.target, &action.payload)
469        } else {
470            None
471        };
472
473        // Validate lifecycle authorization before proceeding.
474        if let Some((ref target_actor_id, ref op)) = lifecycle_op {
475            let initiator = self
476                .actor_store
477                .get(&action.actor_id)
478                .await?
479                .ok_or_else(|| KernelError::ActorNotFound(action.actor_id.clone()))?;
480            let target_actor = self
481                .actor_store
482                .get(target_actor_id)
483                .await?
484                .ok_or_else(|| KernelError::ActorNotFound(target_actor_id.clone()))?;
485            lifecycle::validate_lifecycle_authorization(&initiator, &target_actor, op).await?;
486        }
487
488        let create_actor_spec = parse_create_actor_spec(&action, &self.actor_store).await?;
489        let create_envelope_spec =
490            parse_create_envelope_spec(&action, &self.envelope_store).await?;
491        let policy_version = parse_policy_version(&action);
492
493        // Step 2: QUOTE + Step 3: RESERVE
494        // PIP-001 §11d: Hold-approved actions already have energy reserved at trigger time.
495        // The sentinel `_hold_reserved_cost` carries the pre-reserved amount so we skip
496        // double-charging. We create a phantom EnergyReservation so settle correctly
497        // reduces reserved_energy without calling reserve() again.
498        let (reserved_cost, reservation) = if let Some(hold_reserved) = action
499            .payload
500            .get("_hold_reserved_cost")
501            .and_then(|v| v.as_i64())
502        {
503            let phantom = if hold_reserved > 0 {
504                Some(EnergyReservation {
505                    actor_id: action.actor_id.clone(),
506                    reserved: hold_reserved,
507                })
508            } else {
509                None
510            };
511            (hold_reserved, phantom)
512        } else {
513            // All actions pay quote_cost (action_cost + append_cost).
514            // For observe: action_cost=0 but append_cost >= 1 (Landauer).
515            let cost = quote_cost(&action) as i64;
516            let res = if cost > 0 {
517                Some(self.energy_ledger.reserve(&action.actor_id, cost).await?)
518            } else {
519                None
520            };
521            (cost, res)
522        };
523
524        // Step 4: VALIDATE EXECUTE PAYLOAD (PIP-002 §2 — for Execute actions only)
525        // The kernel does not execute anything. It validates the actor-submitted
526        // result and records it. Actor executes, kernel records.
527        let artifact_hash = if matches!(action.action_type, ActionType::Execute) {
528            Some(Self::validate_execute_payload(&action.payload)?)
529        } else {
530            None
531        };
532
533        // Step 5: SETTLE
534        // For PIP-002: settled cost equals reserved cost (no post-execution IO adjustment).
535        let settled_cost = reserved_cost;
536
537        // Step 6: APPEND
538        let mut event = EventRecord {
539            id: Uuid::new_v4().to_string(),
540            log_index: 0,
541            event_hash: String::new(),
542            actor_id: action.actor_id.clone(),
543            action_type: action.action_type.as_str().to_string(),
544            target: action.target.clone(),
545            payload: action.payload.clone(),
546            payload_hash: payload_hash_hex(&action)?,
547            artifact_hash: artifact_hash.clone(),
548            reserved_energy: reserved_cost,
549            settled_energy: settled_cost,
550            timestamp: now_millis_string(),
551        };
552        self.finalize_energy_and_event(
553            reservation.as_ref(),
554            settled_cost,
555            create_actor_spec.as_ref(),
556            create_envelope_spec.as_ref(),
557            &mut event,
558        )
559        .await?;
560
561        // Step 7: POST-COMMIT — envelope budget consumption + checkpoints + policy + lifecycle
562        if let Some(ref version) = policy_version {
563            if let Err(err) = self.state_store.set_policy_version(version).await {
564                warn!(error = %err, "failed to record policy version after commit");
565            } else {
566                info!(policy_version = %version, event_id = %event.id, "policy version updated");
567            }
568        }
569
570        // Phase 4b: Post-commit envelope budget consumption and checkpoint (PIP-001 §11).
571        if action.action_type.is_state_changing()
572            && settled_cost > 0
573            && let Ok(Some(actor)) = self.actor_store.get(&action.actor_id).await
574            && actor.actor_type == ActorType::Agent
575            && let Ok(Some(envelope)) = self
576                .envelope_store
577                .get_active_for_actor(&action.actor_id)
578                .await
579        {
580            // Check checkpoint BEFORE consuming (so we use pre-consume state).
581            let checkpoint = consent::check_checkpoint(&envelope, settled_cost);
582
583            // Consume budget.
584            let pool = self.state_store.pool();
585            let mut tx = pool.begin().await?;
586            match self
587                .envelope_store
588                .consume_budget_in_tx(&mut tx, &envelope.envelope_id, settled_cost)
589                .await
590            {
591                Ok(_new_consumed) => {
592                    // Handle checkpoint levels (PIP-001 §11).
593                    match checkpoint {
594                        Some(CheckpointLevel::Halt) => {
595                            self.envelope_store
596                                .set_status_in_tx(
597                                    &mut tx,
598                                    &envelope.envelope_id,
599                                    &consent::EnvelopeStatus::Expired,
600                                )
601                                .await?;
602                            info!(
603                                envelope_id = %envelope.envelope_id,
604                                actor_id = %action.actor_id,
605                                "checkpoint: HALT — envelope budget exhausted"
606                            );
607                        }
608                        Some(CheckpointLevel::Report) => {
609                            info!(
610                                envelope_id = %envelope.envelope_id,
611                                actor_id = %action.actor_id,
612                                budget = envelope.budget,
613                                consumed = envelope.budget_consumed + settled_cost,
614                                "checkpoint: REPORT — summary logged"
615                            );
616                        }
617                        None => {}
618                    }
619                    tx.commit().await?;
620                }
621                Err(err) => {
622                    warn!(
623                        error = %err,
624                        "envelope budget consumption failed (event already committed)"
625                    );
626                    // Transaction is automatically rolled back on drop.
627                }
628            }
629        }
630
631        // Phase 4a: Execute lifecycle operation after event is committed.
632        if let Some((ref target_actor_id, ref op)) = lifecycle_op {
633            let pool = self.state_store.pool();
634            match op {
635                punkgo_core::actor::LifecycleOp::Freeze { .. } => {
636                    match lifecycle::execute_freeze(&self.actor_store, &pool, target_actor_id).await
637                    {
638                        Ok(frozen_ids) => {
639                            info!(
640                                target = %target_actor_id,
641                                cascade_count = frozen_ids.len(),
642                                "lifecycle: freeze executed"
643                            );
644                        }
645                        Err(err) => {
646                            warn!(error = %err, "lifecycle: freeze failed (event already committed)");
647                        }
648                    }
649                }
650                punkgo_core::actor::LifecycleOp::Unfreeze => {
651                    match lifecycle::execute_unfreeze(&self.actor_store, &pool, target_actor_id)
652                        .await
653                    {
654                        Ok(()) => {
655                            info!(target = %target_actor_id, "lifecycle: unfreeze executed");
656                        }
657                        Err(err) => {
658                            warn!(error = %err, "lifecycle: unfreeze failed (event already committed)");
659                        }
660                    }
661                }
662                punkgo_core::actor::LifecycleOp::Terminate { .. } => {
663                    // Terminate sets the actor to frozen permanently.
664                    // Orphan handling is a future enhancement — for now, children are cascade-frozen.
665                    match lifecycle::execute_freeze(&self.actor_store, &pool, target_actor_id).await
666                    {
667                        Ok(frozen_ids) => {
668                            info!(
669                                target = %target_actor_id,
670                                cascade_count = frozen_ids.len(),
671                                "lifecycle: terminate executed (cascade frozen)"
672                            );
673                        }
674                        Err(err) => {
675                            warn!(error = %err, "lifecycle: terminate failed (event already committed)");
676                        }
677                    }
678                }
679                punkgo_core::actor::LifecycleOp::UpdateEnergyShare { energy_share } => {
680                    match lifecycle::execute_update_energy_share(
681                        &self.actor_store,
682                        &pool,
683                        target_actor_id,
684                        *energy_share,
685                    )
686                    .await
687                    {
688                        Ok(()) => {
689                            info!(
690                                target = %target_actor_id,
691                                energy_share,
692                                "lifecycle: energy_share updated"
693                            );
694                        }
695                        Err(err) => {
696                            warn!(error = %err, "lifecycle: update_energy_share failed (event already committed)");
697                        }
698                    }
699                }
700            }
701        }
702
703        Ok(SubmitReceipt {
704            event_id: event.id,
705            log_index: event.log_index,
706            event_hash: event.event_hash,
707            reserved_cost,
708            settled_cost,
709            artifact_hash,
710        })
711    }
712
713    async fn finalize_energy_and_event(
714        &self,
715        reservation: Option<&EnergyReservation>,
716        settled_cost: i64,
717        create_actor: Option<&CreateActorSpec>,
718        create_envelope: Option<&(String, EnvelopeSpec, Option<String>)>,
719        event: &mut EventRecord,
720    ) -> KernelResult<()> {
721        let pool = self.state_store.pool();
722        let mut tx = pool.begin().await?;
723
724        // Energy settlement (if reservation exists).
725        if let Some(res) = reservation {
726            self.energy_ledger
727                .settle_in_tx(&mut tx, &res.actor_id, res.reserved, settled_cost)
728                .await?;
729        }
730
731        // Actor creation (if applicable).
732        if let Some(spec) = create_actor {
733            self.energy_ledger
734                .create_actor_in_tx(&mut tx, &spec.actor_id, spec.energy_balance)
735                .await?;
736            self.actor_store.create_in_tx(&mut tx, spec).await?;
737            info!(
738                created_actor = %spec.actor_id,
739                actor_type = spec.actor_type.as_str(),
740                energy_balance = spec.energy_balance,
741                "actor created in transaction"
742            );
743        }
744
745        // Envelope creation (if applicable, whitepaper §3 invariant 6).
746        if let Some((envelope_id, spec, parent_id)) = create_envelope {
747            self.envelope_store
748                .create_in_tx(&mut tx, envelope_id, spec, parent_id.as_deref())
749                .await?;
750            info!(
751                envelope_id = %envelope_id,
752                actor_id = %spec.actor_id,
753                grantor_id = %spec.grantor_id,
754                budget = spec.budget,
755                "envelope created in transaction"
756            );
757        }
758
759        // Event append.
760        self.event_log.append_in_tx(&mut tx, event).await?;
761
762        // Audit trail update — atomic with event (whitepaper §3 invariant 5).
763        let log_index = event.log_index as u64;
764        self.audit_log
765            .append_leaf_in_tx(&mut tx, log_index, &event.event_hash)
766            .await
767            .map_err(|e| KernelError::Audit(e.to_string()))?;
768
769        // Checkpoint is NOT generated on every event. It is a derived artifact
770        // (tree root computed from leaf hashes) and can be generated on-demand
771        // when queried (receipt, show, verify) or via explicit checkpoint command.
772        // This keeps the write path fast and lock-free for concurrent access.
773        tx.commit().await?;
774        info!(event_id = %event.id, log_index = event.log_index, "event committed");
775        Ok(())
776    }
777
778    /// PIP-002 §2+§3: Validate execute action payload.
779    ///
780    /// The kernel MUST reject an execute submission that is missing any required
781    /// field or uses an invalid OID format. Returns the artifact_hash on success.
782    fn validate_execute_payload(payload: &Value) -> KernelResult<String> {
783        Self::require_valid_oid(payload, "input_oid")?;
784        Self::require_valid_oid(payload, "output_oid")?;
785
786        if payload.get("exit_code").and_then(|v| v.as_i64()).is_none() {
787            return Err(KernelError::ExecutePayloadInvalid(
788                "missing or invalid exit_code (must be integer)".to_string(),
789            ));
790        }
791
792        let artifact_hash = Self::require_valid_oid(payload, "artifact_hash")?;
793        Ok(artifact_hash)
794    }
795
796    /// Validate that a payload field exists and matches OID format: `sha256:<64 hex chars>`.
797    fn require_valid_oid(payload: &Value, field: &str) -> KernelResult<String> {
798        let val = payload
799            .get(field)
800            .and_then(|v| v.as_str())
801            .ok_or_else(|| KernelError::ExecutePayloadInvalid(format!("missing {field}")))?;
802
803        if !val.starts_with("sha256:") || val.len() != 71 {
804            return Err(KernelError::ExecutePayloadInvalid(format!(
805                "{field} must be sha256:<64 hex chars>, got: {val}"
806            )));
807        }
808        let hex_part = &val[7..];
809        if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
810            return Err(KernelError::ExecutePayloadInvalid(format!(
811                "{field} contains non-hex characters: {val}"
812            )));
813        }
814
815        Ok(val.to_string())
816    }
817
818    async fn read_query(&self, query: ReadQuery) -> KernelResult<Value> {
819        // Visibility boundary gate for read queries.
820        // Currently all-transparent. When multi-user collaboration is
821        // introduced, check_read_access will enforce readable scope per
822        // requester. See PIP-001 §8 (writable_targets).
823        let requester = query.requester_id.as_deref().unwrap_or("anonymous");
824        check_read_access(requester, &query.kind)?;
825
826        match query.kind.as_str() {
827            "health" => Ok(json!({ "status": "ok" })),
828            "actor_energy" => {
829                let actor_id = query.actor_id.ok_or_else(|| {
830                    KernelError::InvalidRequest("actor_id is required for actor_energy".to_string())
831                })?;
832                let (energy_balance, reserved_energy) =
833                    self.energy_ledger.balance_view(&actor_id).await?;
834                Ok(json!({
835                    "actor_id": actor_id,
836                    "energy_balance": energy_balance,
837                    "reserved_energy": reserved_energy
838                }))
839            }
840            "events" => {
841                let limit = query.limit.unwrap_or(20).clamp(1, 500);
842                let events = self
843                    .event_log
844                    .query(
845                        query.actor_id.as_deref(),
846                        query.before_index,
847                        query.after_index,
848                        limit,
849                    )
850                    .await?;
851                // Pagination metadata: smallest log_index in result set.
852                // Client passes this as `before_index` to fetch the next page.
853                let next_cursor = events.last().map(|e| e.log_index);
854                let has_more = events.len() as i64 == limit;
855                Ok(json!({
856                    "events": events,
857                    "has_more": has_more,
858                    "next_cursor": next_cursor
859                }))
860            }
861            "stats" => {
862                let event_count = self.event_log.count().await?;
863                Ok(json!({ "event_count": event_count }))
864            }
865            "snapshot" => {
866                // Legacy: snapshot is superseded by audit checkpoint.
867                // Return audit checkpoint data for backward compatibility.
868                let event_count = self.event_log.count().await?;
869                self.audit_log
870                    .ensure_checkpoint(event_count as u64)
871                    .await
872                    .map_err(|e| KernelError::Audit(e.to_string()))?;
873                let cp = self
874                    .audit_log
875                    .latest_checkpoint()
876                    .await
877                    .map_err(|e| KernelError::Audit(e.to_string()))?;
878                Ok(json!({
879                    "event_count": event_count,
880                    "snapshot_hash": cp.root_hash,
881                    "generated_at": cp.created_at
882                }))
883            }
884            "paths" => {
885                let paths = self.state_store.paths();
886                Ok(json!({
887                    "root": paths.root.display().to_string(),
888                    "workspace_root": paths.workspace_root.display().to_string(),
889                    "quarantine_root": paths.quarantine_root.display().to_string(),
890                    "db_path": paths.db_path.display().to_string()
891                }))
892            }
893            "audit_checkpoint" => {
894                let event_count = self.event_log.count().await?;
895                self.audit_log
896                    .ensure_checkpoint(event_count as u64)
897                    .await
898                    .map_err(|e| KernelError::Audit(e.to_string()))?;
899                let cp = self
900                    .audit_log
901                    .latest_checkpoint()
902                    .await
903                    .map_err(|e| KernelError::Audit(e.to_string()))?;
904                Ok(serde_json::to_value(cp)?)
905            }
906            "audit_inclusion_proof" => {
907                let log_index = query.log_index.ok_or_else(|| {
908                    KernelError::InvalidRequest(
909                        "log_index is required for audit_inclusion_proof".to_string(),
910                    )
911                })? as u64;
912                let tree_size = match query.tree_size {
913                    Some(s) => s as u64,
914                    None => {
915                        // Ensure checkpoint is current before deriving tree_size.
916                        let event_count = self.event_log.count().await? as u64;
917                        self.audit_log
918                            .ensure_checkpoint(event_count)
919                            .await
920                            .map_err(|e| KernelError::Audit(e.to_string()))?;
921                        self.audit_log
922                            .tree_size()
923                            .await
924                            .map_err(|e| KernelError::Audit(e.to_string()))?
925                    }
926                };
927                let proof = self
928                    .audit_log
929                    .inclusion_proof(log_index, tree_size)
930                    .await
931                    .map_err(|e| KernelError::Audit(e.to_string()))?;
932                Ok(json!({
933                    "log_index": log_index,
934                    "tree_size": tree_size,
935                    "proof": proof
936                }))
937            }
938            "audit_consistency_proof" => {
939                let old_size = query.old_size.ok_or_else(|| {
940                    KernelError::InvalidRequest(
941                        "old_size is required for audit_consistency_proof".to_string(),
942                    )
943                })? as u64;
944                let new_size = query.tree_size.ok_or_else(|| {
945                    KernelError::InvalidRequest(
946                        "tree_size is required for audit_consistency_proof".to_string(),
947                    )
948                })? as u64;
949                let proof = self
950                    .audit_log
951                    .consistency_proof(old_size, new_size)
952                    .await
953                    .map_err(|e| KernelError::Audit(e.to_string()))?;
954                Ok(json!({
955                    "old_size": old_size,
956                    "new_size": new_size,
957                    "proof": proof
958                }))
959            }
960            // Phase 2: stellar configuration read query.
961            "stellar_info" => {
962                let config = &self.stellar_config;
963                Ok(json!({
964                    "gpu_model": config.gpu_model,
965                    "cpu_model": config.cpu_model,
966                    "int8_tops": config.int8_tops,
967                    "energy_per_tick": config.effective_energy_per_tick(),
968                    "tick_interval_ms": config.tick_interval_ms,
969                    "luminosity_source": config.luminosity_source
970                }))
971            }
972            // Phase 4b: envelope_info read query — retrieve active envelope for an actor.
973            "envelope_info" => {
974                let actor_id = query.actor_id.ok_or_else(|| {
975                    KernelError::InvalidRequest(
976                        "actor_id is required for envelope_info".to_string(),
977                    )
978                })?;
979                let envelope = self.envelope_store.get_active_for_actor(&actor_id).await?;
980                match envelope {
981                    Some(record) => Ok(serde_json::to_value(record)?),
982                    None => Ok(json!({ "actor_id": actor_id, "envelope": null })),
983                }
984            }
985            // Phase 1: actor_info read query — retrieve actor record.
986            "actor_info" => {
987                let actor_id = query.actor_id.ok_or_else(|| {
988                    KernelError::InvalidRequest("actor_id is required for actor_info".to_string())
989                })?;
990                let actor = self.actor_store.get(&actor_id).await?;
991                match actor {
992                    Some(record) => Ok(serde_json::to_value(record)?),
993                    None => Err(KernelError::ActorNotFound(actor_id)),
994                }
995            }
996            // PIP-001 §11: hold_info — retrieve a single hold_request by hold_id.
997            "hold_info" => {
998                let hold_id = query.hold_id.ok_or_else(|| {
999                    KernelError::InvalidRequest("hold_id is required for hold_info".to_string())
1000                })?;
1001                let hold = self.envelope_store.get_hold_request(&hold_id).await?;
1002                match hold {
1003                    Some(record) => Ok(record),
1004                    None => Err(KernelError::InvalidRequest(format!(
1005                        "hold_request not found: {hold_id}"
1006                    ))),
1007                }
1008            }
1009            // PIP-001 §11: holds_pending — list pending hold_requests, optionally filtered by agent.
1010            "holds_pending" => {
1011                let holds = self
1012                    .envelope_store
1013                    .list_pending_holds(query.actor_id.as_deref())
1014                    .await?;
1015                Ok(json!({ "holds": holds }))
1016            }
1017            // Phase 1: signing public key — identity binding.
1018            "signing_pubkey" => Ok(json!({
1019                "pubkey_hex": self.signing_key.public_key_hex(),
1020                "algorithm": "ed25519"
1021            })),
1022            other => Err(KernelError::InvalidRequest(format!(
1023                "unsupported read query kind: {other}"
1024            ))),
1025        }
1026    }
1027}
1028
1029fn now_millis_string() -> String {
1030    let now = std::time::SystemTime::now()
1031        .duration_since(std::time::UNIX_EPOCH)
1032        .unwrap_or_default();
1033    now.as_millis().to_string()
1034}
1035
1036fn now_millis_u64() -> u64 {
1037    std::time::SystemTime::now()
1038        .duration_since(std::time::UNIX_EPOCH)
1039        .unwrap_or_default()
1040        .as_millis() as u64
1041}
1042
1043/// If the action is a `system/policy` Create, extract the policy version string.
1044/// Returns `None` for all other actions.
1045fn parse_policy_version(action: &Action) -> Option<String> {
1046    if !matches!(action.action_type, ActionType::Create) || action.target != "system/policy" {
1047        return None;
1048    }
1049    action
1050        .payload
1051        .get("version")
1052        .and_then(|v| v.as_str())
1053        .map(|s| s.to_string())
1054}
1055
1056/// Phase 1: Parse actor creation spec from a `ledger/actor` Create action.
1057///
1058/// Backward compatible: old format `{actor_id, energy_balance}` is supported
1059/// by defaulting to actor_type=agent, purpose="legacy", writable_targets=[].
1060///
1061/// New format adds: actor_type, purpose, writable_targets, energy_share.
1062async fn parse_create_actor_spec(
1063    action: &Action,
1064    actor_store: &ActorStore,
1065) -> KernelResult<Option<CreateActorSpec>> {
1066    if !matches!(action.action_type, ActionType::Create) || action.target != "ledger/actor" {
1067        return Ok(None);
1068    }
1069
1070    let payload = action.payload.as_object().ok_or_else(|| {
1071        KernelError::InvalidRequest("seed actor payload must be an object".to_string())
1072    })?;
1073
1074    // Required: actor_id (or derive from purpose)
1075    let explicit_id = payload.get("actor_id").and_then(Value::as_str);
1076    let purpose = payload
1077        .get("purpose")
1078        .and_then(Value::as_str)
1079        .unwrap_or("legacy");
1080    let energy_balance = payload
1081        .get("energy_balance")
1082        .and_then(Value::as_i64)
1083        .unwrap_or(1000);
1084
1085    // Actor type: default to "agent" for backward compat
1086    let actor_type_str = payload
1087        .get("actor_type")
1088        .and_then(Value::as_str)
1089        .unwrap_or("agent");
1090    let actor_type = ActorType::parse(actor_type_str).ok_or_else(|| {
1091        KernelError::InvalidRequest(format!("invalid actor_type: {actor_type_str}"))
1092    })?;
1093
1094    // Derive ID if not explicitly provided
1095    let actor_id = if let Some(id) = explicit_id {
1096        id.to_string()
1097    } else {
1098        if purpose == "legacy" {
1099            return Err(KernelError::InvalidRequest(
1100                "actor_id or purpose is required to create an actor".to_string(),
1101            ));
1102        }
1103        let seq = actor_store.next_sequence(&action.actor_id, purpose).await?;
1104        derive_agent_id(&action.actor_id, purpose, seq)
1105    };
1106
1107    // Build lineage from creator (PIP-001 §7) + enforce §5 (only humans create agents)
1108    let creator_record = actor_store.get(&action.actor_id).await?;
1109    let (creator_type, creator_lineage) = match &creator_record {
1110        Some(record) => (record.actor_type.clone(), record.lineage.clone()),
1111        // Fallback for legacy actors not yet in actors table
1112        None => (ActorType::Human, vec![]),
1113    };
1114
1115    // PIP-001 §5: Only Humans can create Agents. Reject Agent as creator.
1116    if creator_type == ActorType::Agent && actor_type == ActorType::Agent {
1117        return Err(KernelError::PolicyViolation(
1118            "agents cannot create agents — creation right belongs to humans (PIP-001 §5)"
1119                .to_string(),
1120        ));
1121    }
1122
1123    let lineage = build_lineage(&creator_type, &action.actor_id, &creator_lineage);
1124
1125    // Writable targets: parse from payload or default to empty
1126    let writable_targets: Vec<WritableTarget> = if let Some(targets_val) =
1127        payload.get("writable_targets")
1128    {
1129        serde_json::from_value(targets_val.clone())
1130            .map_err(|e| KernelError::InvalidRequest(format!("invalid writable_targets: {e}")))?
1131    } else {
1132        vec![]
1133    };
1134
1135    // Phase 3 PIP-001 §11: validate child targets are subset of creator's boundary.
1136    if !writable_targets.is_empty()
1137        && let Some(ref creator) = creator_record
1138    {
1139        validate_child_targets(creator, &writable_targets)?;
1140    }
1141
1142    // Energy share: default 0.0
1143    let energy_share = payload
1144        .get("energy_share")
1145        .and_then(Value::as_f64)
1146        .unwrap_or(0.0);
1147
1148    let reduction_policy = payload
1149        .get("reduction_policy")
1150        .and_then(Value::as_str)
1151        .unwrap_or("none")
1152        .to_string();
1153
1154    Ok(Some(CreateActorSpec {
1155        actor_id,
1156        actor_type,
1157        creator_id: action.actor_id.clone(),
1158        lineage,
1159        purpose: Some(purpose.to_string()),
1160        writable_targets,
1161        energy_balance,
1162        energy_share,
1163        reduction_policy,
1164    }))
1165}
1166
1167/// Phase 4b: Parse envelope creation spec from a `ledger/envelope` Create action.
1168///
1169/// Returns (envelope_id, EnvelopeSpec, parent_envelope_id) if applicable.
1170/// The grantor is always the action's actor_id (the human or parent agent granting
1171/// the envelope).
1172///
1173/// PIP-001 §11: If the grantor is an agent, their active envelope is used as the parent
1174/// and reduction validation is performed.
1175async fn parse_create_envelope_spec(
1176    action: &Action,
1177    envelope_store: &EnvelopeStore,
1178) -> KernelResult<Option<(String, EnvelopeSpec, Option<String>)>> {
1179    if !matches!(action.action_type, ActionType::Create) || action.target != "ledger/envelope" {
1180        return Ok(None);
1181    }
1182
1183    let payload = action.payload.as_object().ok_or_else(|| {
1184        KernelError::InvalidRequest("envelope payload must be an object".to_string())
1185    })?;
1186
1187    let actor_id = payload
1188        .get("actor_id")
1189        .and_then(Value::as_str)
1190        .ok_or_else(|| {
1191            KernelError::InvalidRequest("actor_id is required to create an envelope".to_string())
1192        })?
1193        .to_string();
1194
1195    let budget = payload
1196        .get("budget")
1197        .and_then(Value::as_i64)
1198        .ok_or_else(|| {
1199            KernelError::InvalidRequest("budget is required to create an envelope".to_string())
1200        })?;
1201
1202    if budget <= 0 {
1203        return Err(KernelError::InvalidRequest(
1204            "envelope budget must be positive".to_string(),
1205        ));
1206    }
1207
1208    let targets: Vec<String> = if let Some(targets_val) = payload.get("targets") {
1209        serde_json::from_value(targets_val.clone())
1210            .map_err(|e| KernelError::InvalidRequest(format!("invalid envelope targets: {e}")))?
1211    } else {
1212        return Err(KernelError::InvalidRequest(
1213            "targets are required to create an envelope".to_string(),
1214        ));
1215    };
1216
1217    let actions: Vec<String> = if let Some(actions_val) = payload.get("actions") {
1218        serde_json::from_value(actions_val.clone())
1219            .map_err(|e| KernelError::InvalidRequest(format!("invalid envelope actions: {e}")))?
1220    } else {
1221        return Err(KernelError::InvalidRequest(
1222            "actions are required to create an envelope".to_string(),
1223        ));
1224    };
1225
1226    let duration_secs = payload.get("duration_secs").and_then(Value::as_i64);
1227    let report_every = payload.get("report_every").and_then(Value::as_i64);
1228    let hold_timeout_secs = payload.get("hold_timeout_secs").and_then(Value::as_i64);
1229
1230    // PIP-001 §11a: Parse hold_on rules.
1231    let hold_on: Vec<HoldRule> = if let Some(hold_on_val) = payload.get("hold_on") {
1232        serde_json::from_value(hold_on_val.clone())
1233            .map_err(|e| KernelError::InvalidRequest(format!("invalid hold_on rules: {e}")))?
1234    } else {
1235        vec![]
1236    };
1237
1238    let spec = EnvelopeSpec {
1239        actor_id,
1240        grantor_id: action.actor_id.clone(),
1241        budget,
1242        targets,
1243        actions,
1244        duration_secs,
1245        report_every,
1246        hold_on,
1247        hold_timeout_secs,
1248    };
1249
1250    // PIP-001 §11: If grantor is an agent with an active envelope, validate reduction.
1251    let parent_envelope_id = if let Some(grantor_envelope) = envelope_store
1252        .get_active_for_actor(&action.actor_id)
1253        .await?
1254    {
1255        consent::validate_envelope_reduction(&grantor_envelope, &spec)?;
1256        Some(grantor_envelope.envelope_id)
1257    } else {
1258        None
1259    };
1260
1261    let envelope_id = Uuid::new_v4().to_string();
1262
1263    Ok(Some((envelope_id, spec, parent_envelope_id)))
1264}
1265
1266// ---------------------------------------------------------------------------
1267// PIP-001 §11d: Hold response parsing and execution
1268// ---------------------------------------------------------------------------
1269
1270/// Parse a `ledger/hold/<hold_id>` mutate target into (hold_id, decision, instruction).
1271///
1272/// Returns `None` for any other target.
1273fn parse_hold_response(target: &str, payload: &Value) -> Option<(String, String, Option<String>)> {
1274    // Pattern: "ledger/hold/<hold_id>"
1275    let rest = target.strip_prefix("ledger/hold/")?;
1276    // Ensure there is a non-empty hold_id segment and nothing after it.
1277    if rest.is_empty() || rest.contains('/') {
1278        return None;
1279    }
1280    let hold_id = rest.to_string();
1281
1282    let decision = payload.get("decision").and_then(Value::as_str)?.to_string();
1283
1284    // Validate decision value.
1285    if decision != "approve" && decision != "reject" {
1286        return None;
1287    }
1288
1289    let instruction = payload
1290        .get("instruction")
1291        .and_then(Value::as_str)
1292        .map(|s| s.to_string());
1293
1294    Some((hold_id, decision, instruction))
1295}
1296
1297impl Kernel {
1298    /// PIP-001 §11d: Execute a hold_response (approve or reject).
1299    ///
1300    /// Validates that:
1301    /// 1. The caller is a Human actor (whitepaper §2: Human sovereignty).
1302    /// 2. The hold_request exists and is still pending.
1303    /// 3. The hold envelope belongs to an Agent actor.
1304    ///
1305    /// On approve: re-executes the pending action and writes a `hold_response` event.
1306    /// On reject: discards the pending action and writes a `hold_response` event.
1307    async fn execute_hold_response(
1308        &self,
1309        human_action: &Action,
1310        hold_id: &str,
1311        decision: &str,
1312        instruction: Option<&str>,
1313    ) -> KernelResult<SubmitReceipt> {
1314        // --- Validate caller is Human ---
1315        let caller = self
1316            .actor_store
1317            .get(&human_action.actor_id)
1318            .await?
1319            .ok_or_else(|| KernelError::ActorNotFound(human_action.actor_id.clone()))?;
1320
1321        if caller.actor_type != punkgo_core::actor::ActorType::Human {
1322            return Err(KernelError::PolicyViolation(format!(
1323                "only Human actors can resolve holds; caller {} is {:?}",
1324                human_action.actor_id, caller.actor_type
1325            )));
1326        }
1327
1328        // --- Load hold_request ---
1329        let hold_record = self
1330            .envelope_store
1331            .get_hold_request(hold_id)
1332            .await?
1333            .ok_or_else(|| {
1334                KernelError::PolicyViolation(format!("hold_request not found: {hold_id}"))
1335            })?;
1336
1337        let _envelope_id = hold_record
1338            .get("envelope_id")
1339            .and_then(Value::as_str)
1340            .ok_or_else(|| {
1341                KernelError::PolicyViolation("hold_request missing envelope_id".to_string())
1342            })?
1343            .to_string();
1344
1345        let agent_id = hold_record
1346            .get("agent_id")
1347            .and_then(Value::as_str)
1348            .ok_or_else(|| {
1349                KernelError::PolicyViolation("hold_request missing agent_id".to_string())
1350            })?
1351            .to_string();
1352
1353        let pending_payload = hold_record
1354            .get("pending_payload")
1355            .cloned()
1356            .unwrap_or(Value::Null);
1357
1358        let trigger_target = hold_record
1359            .get("trigger_target")
1360            .and_then(Value::as_str)
1361            .unwrap_or("")
1362            .to_string();
1363
1364        let trigger_action = hold_record
1365            .get("trigger_action")
1366            .and_then(Value::as_str)
1367            .unwrap_or("")
1368            .to_string();
1369
1370        let status = hold_record
1371            .get("status")
1372            .and_then(Value::as_str)
1373            .unwrap_or("");
1374        if status != "pending" {
1375            return Err(KernelError::PolicyViolation(format!(
1376                "hold_request {hold_id} is already resolved (status={status})"
1377            )));
1378        }
1379
1380        // PIP-001 §11e: Check if this hold has already timed out.
1381        // If so, run the lazy expiry first — it will auto-reject the hold.
1382        let envelope_id_str = hold_record
1383            .get("envelope_id")
1384            .and_then(Value::as_str)
1385            .unwrap_or("")
1386            .to_string();
1387        if let Ok(Some(env)) = self.envelope_store.get(&envelope_id_str).await {
1388            if let Some(timeout_secs) = env.hold_timeout_secs {
1389                if timeout_secs > 0 {
1390                    let triggered_at: u64 = hold_record
1391                        .get("triggered_at")
1392                        .and_then(|v| v.as_str())
1393                        .and_then(|s| s.parse().ok())
1394                        .unwrap_or(0);
1395                    let now = now_millis_u64();
1396                    if now.saturating_sub(triggered_at) > (timeout_secs as u64) * 1000 {
1397                        // Expire this hold first, then return error.
1398                        self.expire_timed_out_holds(&envelope_id_str, timeout_secs)
1399                            .await?;
1400                        return Err(KernelError::PolicyViolation(format!(
1401                            "hold_request {hold_id} has timed out and was auto-rejected"
1402                        )));
1403                    }
1404                }
1405            }
1406        }
1407
1408        // --- Build hold_response event payload (PIP-001 §11d) ---
1409        let response_payload = json!({
1410            "hold_id": hold_id,
1411            "agent_id": &agent_id,
1412            "decision": decision,
1413            "instruction": instruction,
1414            "trigger": {
1415                "target": &trigger_target,
1416                "action_type": &trigger_action
1417            },
1418            "resolved_by": &human_action.actor_id,
1419            "resolved_at": now_millis_string()
1420        });
1421
1422        let response_action = Action {
1423            actor_id: human_action.actor_id.clone(),
1424            action_type: ActionType::Mutate,
1425            target: format!("ledger/hold/{hold_id}"),
1426            payload: response_payload.clone(),
1427            timestamp: None,
1428        };
1429
1430        // --- Atomic transaction: resolve hold_request + settle energy + write event ---
1431        // Envelope stays Active throughout — no status change needed (PIP-001 §11b).
1432        let pool = self.state_store.pool();
1433        let mut tx = pool.begin().await?;
1434
1435        self.envelope_store
1436            .resolve_hold_request_in_tx(&mut tx, hold_id, decision, instruction)
1437            .await?;
1438
1439        // PIP-001 §11f: On reject, settle commitment cost (20% of reserved).
1440        // settle_in_tx(reserved=full, actual=commitment) releases the rest.
1441        let hold_reserved_cost = pending_payload
1442            .get("reserved_cost")
1443            .and_then(|v| v.as_i64())
1444            .unwrap_or(0);
1445
1446        let commitment_cost = if decision == "reject" && hold_reserved_cost > 0 {
1447            let cost = ((hold_reserved_cost as f64) * 0.2).ceil() as i64;
1448            self.energy_ledger
1449                .settle_in_tx(&mut tx, &agent_id, hold_reserved_cost, cost)
1450                .await?;
1451            cost
1452        } else {
1453            0
1454        };
1455
1456        let mut response_event = EventRecord {
1457            id: Uuid::new_v4().to_string(),
1458            log_index: 0,
1459            event_hash: String::new(),
1460            actor_id: human_action.actor_id.clone(),
1461            action_type: "hold_response".to_string(),
1462            target: format!("ledger/hold/{hold_id}"),
1463            payload: response_payload.clone(),
1464            payload_hash: payload_hash_hex(&response_action)?,
1465            artifact_hash: None,
1466            reserved_energy: hold_reserved_cost,
1467            settled_energy: commitment_cost,
1468            timestamp: now_millis_string(),
1469        };
1470        self.event_log
1471            .append_in_tx(&mut tx, &mut response_event)
1472            .await?;
1473
1474        // Audit trail — atomic with event (whitepaper §3 invariant 5).
1475        let log_index = response_event.log_index as u64;
1476        self.audit_log
1477            .append_leaf_in_tx(&mut tx, log_index, &response_event.event_hash)
1478            .await
1479            .map_err(|e| KernelError::Audit(e.to_string()))?;
1480        // Checkpoint generated lazily on read, not here.
1481        tx.commit().await?;
1482
1483        info!(
1484            hold_id = %hold_id,
1485            decision = %decision,
1486            agent_id = %agent_id,
1487            resolved_by = %human_action.actor_id,
1488            "PIP-001 §11d: hold resolved"
1489        );
1490
1491        // --- Approve: re-execute the pending action (PIP-001 §11d) ---
1492        if decision == "approve" {
1493            // Reconstruct the original action from the pending_payload stored in hold_request.
1494            let orig_target = pending_payload
1495                .get("target")
1496                .and_then(Value::as_str)
1497                .unwrap_or(&trigger_target)
1498                .to_string();
1499            let orig_action_type_str = pending_payload
1500                .get("action_type")
1501                .and_then(Value::as_str)
1502                .unwrap_or(&trigger_action)
1503                .to_string();
1504            let mut orig_payload = pending_payload
1505                .get("payload")
1506                .cloned()
1507                .unwrap_or(Value::Null);
1508
1509            // PIP-001 §11d: Energy was already reserved at hold trigger time.
1510            // `reserved_cost` was stored in pending_payload by the hold trigger.
1511            // Inject `_hold_reserved_cost` so submit_action skips double quote/reserve.
1512
1513            // Mark the payload as hold-approved so the re-execution bypasses the hold check (PIP-001 §11d).
1514            // This sentinel prevents a re-trigger loop.
1515            if let Some(obj) = orig_payload.as_object_mut() {
1516                obj.insert("_hold_approved".to_string(), Value::Bool(true));
1517                obj.insert(
1518                    "_hold_reserved_cost".to_string(),
1519                    Value::Number(hold_reserved_cost.into()),
1520                );
1521                // PIP-001 §11d: If instruction is present, append it to the payload.
1522                if let Some(instr) = instruction {
1523                    obj.insert(
1524                        "_hold_instruction".to_string(),
1525                        Value::String(instr.to_string()),
1526                    );
1527                }
1528            } else {
1529                // If payload is not an object (e.g. null), wrap it.
1530                let mut obj = serde_json::Map::new();
1531                obj.insert("_hold_approved".to_string(), Value::Bool(true));
1532                obj.insert(
1533                    "_hold_reserved_cost".to_string(),
1534                    Value::Number(hold_reserved_cost.into()),
1535                );
1536                if let Some(instr) = instruction {
1537                    obj.insert(
1538                        "_hold_instruction".to_string(),
1539                        Value::String(instr.to_string()),
1540                    );
1541                }
1542                orig_payload = Value::Object(obj);
1543            }
1544
1545            let orig_action_type = match orig_action_type_str.as_str() {
1546                "observe" => ActionType::Observe,
1547                "create" => ActionType::Create,
1548                "mutate" => ActionType::Mutate,
1549                _ => ActionType::Execute,
1550            };
1551
1552            let pending_action = Action {
1553                actor_id: agent_id.clone(),
1554                action_type: orig_action_type,
1555                target: orig_target,
1556                payload: orig_payload,
1557                timestamp: None,
1558            };
1559
1560            info!(
1561                hold_id = %hold_id,
1562                agent_id = %agent_id,
1563                "PIP-001 §11d: re-executing approved pending action"
1564            );
1565
1566            // Re-submit the pending action on behalf of the agent.
1567            // This goes through the full pipeline (quota → reserve → execute → settle → append).
1568            // Use Box::pin to break the async recursion (submit_action → execute_hold_response → submit_action).
1569            return Box::pin(self.submit_action(pending_action)).await;
1570        }
1571
1572        // --- Reject: return a receipt with commitment cost (PIP-001 §11f) ---
1573        Ok(SubmitReceipt {
1574            event_id: response_event.id,
1575            log_index: response_event.log_index,
1576            event_hash: response_event.event_hash,
1577            reserved_cost: hold_reserved_cost,
1578            settled_cost: commitment_cost,
1579            artifact_hash: None,
1580        })
1581    }
1582
1583    /// PIP-001 §11e: Lazy expiry for timed-out holds.
1584    ///
1585    /// Called before hold-trigger checks in submit_action. Scans pending holds
1586    /// for the given envelope and auto-rejects any that have exceeded
1587    /// `hold_timeout_secs`. Same pattern as `is_envelope_expired()` — no
1588    /// background thread, just check-on-access.
1589    async fn expire_timed_out_holds(
1590        &self,
1591        envelope_id: &str,
1592        hold_timeout_secs: i64,
1593    ) -> KernelResult<()> {
1594        let holds = self
1595            .envelope_store
1596            .list_pending_holds_for_envelope(envelope_id)
1597            .await?;
1598        let now = now_millis_u64();
1599
1600        for hold in holds {
1601            let triggered_at: u64 = hold
1602                .get("triggered_at")
1603                .and_then(|v| v.as_str())
1604                .and_then(|s| s.parse().ok())
1605                .unwrap_or(0);
1606
1607            if now.saturating_sub(triggered_at) <= (hold_timeout_secs as u64) * 1000 {
1608                continue;
1609            }
1610
1611            // Timed out — treat as reject (PIP-001 §11e/§11f).
1612            let hold_id = hold.get("hold_id").and_then(|v| v.as_str()).unwrap_or("");
1613            let agent_id = hold.get("agent_id").and_then(|v| v.as_str()).unwrap_or("");
1614            let pending_payload = hold.get("pending_payload").cloned().unwrap_or(Value::Null);
1615            let reserved_cost = pending_payload
1616                .get("reserved_cost")
1617                .and_then(|v| v.as_i64())
1618                .unwrap_or(0);
1619
1620            let commitment_cost = if reserved_cost > 0 {
1621                ((reserved_cost as f64) * 0.2).ceil() as i64
1622            } else {
1623                0
1624            };
1625
1626            let timeout_payload = json!({
1627                "hold_id": hold_id,
1628                "agent_id": agent_id,
1629                "decision": "timed_out",
1630                "reserved_cost": reserved_cost,
1631                "commitment_cost": commitment_cost,
1632                "triggered_at": triggered_at.to_string(),
1633                "expired_at": now.to_string()
1634            });
1635            let timeout_action = Action {
1636                actor_id: agent_id.to_string(),
1637                action_type: ActionType::Mutate,
1638                target: format!("ledger/hold/{hold_id}"),
1639                payload: timeout_payload.clone(),
1640                timestamp: None,
1641            };
1642            let mut timeout_event = EventRecord {
1643                id: Uuid::new_v4().to_string(),
1644                log_index: 0,
1645                event_hash: String::new(),
1646                actor_id: agent_id.to_string(),
1647                action_type: "hold_timeout".to_string(),
1648                target: format!("ledger/hold/{hold_id}"),
1649                payload: timeout_payload,
1650                payload_hash: payload_hash_hex(&timeout_action)?,
1651                artifact_hash: None,
1652                reserved_energy: reserved_cost,
1653                settled_energy: commitment_cost,
1654                timestamp: now_millis_string(),
1655            };
1656
1657            let pool = self.state_store.pool();
1658            let mut tx = pool.begin().await?;
1659            self.envelope_store
1660                .resolve_hold_request_in_tx(&mut tx, hold_id, "timed_out", None)
1661                .await?;
1662            if reserved_cost > 0 {
1663                self.energy_ledger
1664                    .settle_in_tx(&mut tx, agent_id, reserved_cost, commitment_cost)
1665                    .await?;
1666            }
1667            self.event_log
1668                .append_in_tx(&mut tx, &mut timeout_event)
1669                .await?;
1670
1671            // Audit trail — atomic with event (whitepaper §3 invariant 5).
1672            let t_log_index = timeout_event.log_index as u64;
1673            self.audit_log
1674                .append_leaf_in_tx(&mut tx, t_log_index, &timeout_event.event_hash)
1675                .await
1676                .map_err(|e| KernelError::Audit(e.to_string()))?;
1677            // Checkpoint generated lazily on read, not here.
1678            tx.commit().await?;
1679
1680            info!(
1681                hold_id = %hold_id,
1682                agent_id = %agent_id,
1683                commitment_cost = commitment_cost,
1684                "PIP-001 §11e: hold auto-expired (timeout)"
1685            );
1686        }
1687        Ok(())
1688    }
1689}