Skip to main content

tenzro_types/
kill_switch.rs

1//! Kill-switch primitive types (Agent-Swarm Spec 1).
2//!
3//! `KillSwitchReceipt` is the canonical on-chain artifact written when a
4//! `PauseAgent` / `QuarantineAgent` / `TerminateAgent` precompile fires.
5//! It is consumed by:
6//!
7//! - The node's `KillSwitchStore` (RocksDB CF_SETTLEMENTS write-through).
8//! - The `tenzro_listKillSwitchByAgent` / `tenzro_listKillSwitchByController`
9//!   RPC handlers.
10//! - The `AgentRuntime` lifecycle dispatcher, which mirrors the action onto
11//!   the in-memory `AgentLifecycle` state machine after the VM commits.
12//!
13//! The receipt is structurally simple: it commits the action, the actor,
14//! the target, the reason, and (for terminate) the slash + cascade flags,
15//! plus a `frozen_at_block` so auditors can correlate against block state.
16//! Receipt id derivation is deterministic for replay-resistance:
17//! `id = SHA-256("tenzro/killswitch/receipt" || action || agent_did ||
18//!  controller_did || block_height_le)`.
19
20use serde::{Deserialize, Serialize};
21
22use crate::primitives::{BlockHeight, Timestamp};
23
24/// Tier of intervention performed by the kill-switch.
25///
26/// Distinct from `tenzro_agent::lifecycle::AgentState` — that is the
27/// runtime FSM, this is the on-chain action label that produced the
28/// transition. The two are not 1:1: an `Active → Quarantined` transition
29/// can come from either a controller `Quarantine` or an escalated
30/// `Pause → Quarantine`. This field records *what was issued*, not *what
31/// the agent's resulting state is* (the receipt does not duplicate the
32/// FSM — readers join on `agent_did`).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum KillSwitchAction {
35    /// Reversible pause (tier 1).
36    Pause,
37    /// Reversible quarantine (tier 2).
38    Quarantine,
39    /// Irreversible termination (tier 3).
40    Terminate,
41}
42
43impl KillSwitchAction {
44    /// Human-readable label used in log topics, RPC envelopes, and CF
45    /// receipt-id derivation.
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            KillSwitchAction::Pause => "pause",
49            KillSwitchAction::Quarantine => "quarantine",
50            KillSwitchAction::Terminate => "terminate",
51        }
52    }
53}
54
55/// Receipt written to CF_SETTLEMENTS for every successful kill-switch
56/// precompile execution. Indexed by both `agent_did` and `controller_did`
57/// for the listing RPCs.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct KillSwitchReceipt {
60    /// Deterministic 32-byte receipt id (hex-encoded). See module docs.
61    pub receipt_id: String,
62    /// What was issued.
63    pub action: KillSwitchAction,
64    /// DID of the agent the action targeted (`did:tenzro:machine:...`).
65    pub agent_did: String,
66    /// DID of the controller / committee / governance proposer that
67    /// authorised the action. For cascaded terminations this is the
68    /// upstream parent's controller — the cascaded children inherit the
69    /// authority of the root termination.
70    pub controller_did: String,
71    /// Canonical reason code (see kill-switch spec §"Reason codes").
72    pub reason_code: u16,
73    /// Optional human-readable reason text, capped at 256 bytes by the
74    /// transaction validator.
75    pub reason_text: Option<String>,
76    /// Optional commitment hash to off-chain evidence (32-byte SHA-256
77    /// hex). Only meaningful for `Quarantine` and `Terminate`.
78    pub evidence_hash: Option<String>,
79    /// For `Terminate` only: basis points of stake to slash, capped per
80    /// governance `slash_bps_cap`. `None` for Pause / Quarantine.
81    pub slash_bps: Option<u16>,
82    /// For `Terminate` only: whether the action recursively terminates
83    /// descendants under the agent's `children:` index.
84    pub cascade: Option<bool>,
85    /// For `Pause` only: optional auto-resume deadline (Unix seconds).
86    /// `None` means an indefinite pause that requires an explicit resume
87    /// action. Always `None` for `Quarantine` / `Terminate`.
88    pub pause_until: Option<u64>,
89    /// Block height at which the action was finalized.
90    pub frozen_at_block: BlockHeight,
91    /// Wall-clock timestamp of finalization (informational; the
92    /// authoritative ordering is the block height).
93    pub timestamp: Timestamp,
94}
95
96impl KillSwitchReceipt {
97    /// Returns true if the receipt represents a reversible action.
98    pub fn is_reversible(&self) -> bool {
99        matches!(
100            self.action,
101            KillSwitchAction::Pause | KillSwitchAction::Quarantine
102        )
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn action_roundtrip_strings() {
112        assert_eq!(KillSwitchAction::Pause.as_str(), "pause");
113        assert_eq!(KillSwitchAction::Quarantine.as_str(), "quarantine");
114        assert_eq!(KillSwitchAction::Terminate.as_str(), "terminate");
115    }
116
117    #[test]
118    fn receipt_serde_roundtrip() {
119        let r = KillSwitchReceipt {
120            receipt_id: "deadbeef".to_string(),
121            action: KillSwitchAction::Quarantine,
122            agent_did: "did:tenzro:machine:alice".to_string(),
123            controller_did: "did:tenzro:human:bob".to_string(),
124            reason_code: 100,
125            reason_text: Some("safety".into()),
126            evidence_hash: None,
127            slash_bps: None,
128            cascade: None,
129            pause_until: None,
130            frozen_at_block: BlockHeight::new(42),
131            timestamp: Timestamp::new(1_700_000_000_000),
132        };
133        let json = serde_json::to_string(&r).unwrap();
134        let back: KillSwitchReceipt = serde_json::from_str(&json).unwrap();
135        assert_eq!(r, back);
136        assert!(r.is_reversible());
137    }
138
139    #[test]
140    fn terminate_is_irreversible() {
141        let r = KillSwitchReceipt {
142            receipt_id: "abc".to_string(),
143            action: KillSwitchAction::Terminate,
144            agent_did: "did:tenzro:machine:alice".to_string(),
145            controller_did: "did:tenzro:human:bob".to_string(),
146            reason_code: 200,
147            reason_text: None,
148            evidence_hash: None,
149            slash_bps: Some(10_000),
150            cascade: Some(true),
151            pause_until: None,
152            frozen_at_block: BlockHeight::new(0),
153            timestamp: Timestamp::new(0),
154        };
155        assert!(!r.is_reversible());
156    }
157}