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}