Skip to main content

systemprompt_identifiers/
actor.rs

1//! Principal + surface attribution for audit and event rows.
2//!
3//! Every actor-bearing row persists `(user_id, kind, kind.actor_id())` as a
4//! unit; the three values cannot be separated at the call site because they
5//! live inside [`Actor`]. The `user_id` is always a real `users` row — the
6//! kind disambiguates which surface ran on that user's behalf.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::UserId;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct Actor {
16    pub user_id: UserId,
17    pub kind: ActorKind,
18}
19
20impl Actor {
21    #[must_use]
22    pub const fn user(user_id: UserId) -> Self {
23        Self {
24            user_id,
25            kind: ActorKind::User,
26        }
27    }
28
29    /// Unauthenticated traffic that has already been bound to a real
30    /// (typically ephemeral) `anonymous_*` user row. The `user_id` is the
31    /// provisioned row's id, not a sentinel.
32    #[must_use]
33    pub const fn anonymous(user_id: UserId) -> Self {
34        Self {
35            user_id,
36            kind: ActorKind::Anonymous,
37        }
38    }
39
40    /// Platform-originated work (bootstrap jobs, scheduler tick, internal
41    /// fallbacks). The caller passes the resolved system-admin user id;
42    /// no sentinel is fabricated inside the constructor.
43    #[must_use]
44    pub const fn system(user_id: UserId) -> Self {
45        Self {
46            user_id,
47            kind: ActorKind::System,
48        }
49    }
50
51    #[must_use]
52    pub fn job(user_id: UserId, job_name: impl Into<String>) -> Self {
53        Self {
54            user_id,
55            kind: ActorKind::Job {
56                job_name: job_name.into(),
57            },
58        }
59    }
60
61    #[must_use]
62    pub fn mcp(user_id: UserId, server_name: impl Into<String>) -> Self {
63        Self {
64            user_id,
65            kind: ActorKind::Mcp {
66                server_name: server_name.into(),
67            },
68        }
69    }
70
71    /// A configured agent (Claude Code session, autonomous agent, etc.)
72    /// acting on the user's behalf. The agent is the surface; the user is
73    /// the accountable principal.
74    #[must_use]
75    pub fn agent(user_id: UserId, agent_id: impl Into<String>) -> Self {
76        Self {
77            user_id,
78            kind: ActorKind::Agent {
79                agent_id: agent_id.into(),
80            },
81        }
82    }
83
84    #[must_use]
85    pub fn audit_columns(&self) -> (&str, &str) {
86        (self.kind.as_str(), self.kind.actor_id(&self.user_id))
87    }
88
89    #[must_use]
90    pub fn from_tool_name(user_id: UserId, agent_id: Option<&str>, tool_name: &str) -> Self {
91        if let Some(rest) = tool_name.strip_prefix("mcp__") {
92            if let Some(server) = rest.split("__").next() {
93                if !server.is_empty() {
94                    return Self::mcp(user_id, server);
95                }
96            }
97        }
98        match agent_id {
99            Some(id) if !id.is_empty() => Self::agent(user_id, id),
100            _ => Self::user(user_id),
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "kind", rename_all = "snake_case")]
107pub enum ActorKind {
108    User,
109    Anonymous,
110    System,
111    Job { job_name: String },
112    Mcp { server_name: String },
113    Agent { agent_id: String },
114}
115
116impl ActorKind {
117    #[must_use]
118    pub const fn as_str(&self) -> &'static str {
119        match self {
120            Self::User => "user",
121            Self::Anonymous => "anonymous",
122            Self::System => "system",
123            Self::Job { .. } => "job",
124            Self::Mcp { .. } => "mcp",
125            Self::Agent { .. } => "agent",
126        }
127    }
128
129    #[must_use]
130    pub fn actor_id<'a>(&'a self, user_id: &'a UserId) -> &'a str {
131        match self {
132            Self::User | Self::Anonymous | Self::System => user_id.as_str(),
133            Self::Job { job_name } => job_name.as_str(),
134            Self::Mcp { server_name } => server_name.as_str(),
135            Self::Agent { agent_id } => agent_id.as_str(),
136        }
137    }
138}
139
140impl ActorKind {
141    #[must_use]
142    pub const fn tag(&self) -> ActorKindTag {
143        match self {
144            Self::User => ActorKindTag::User,
145            Self::Anonymous => ActorKindTag::Anonymous,
146            Self::System => ActorKindTag::System,
147            Self::Job { .. } => ActorKindTag::Job,
148            Self::Mcp { .. } => ActorKindTag::Mcp,
149            Self::Agent { .. } => ActorKindTag::Agent,
150        }
151    }
152}
153
154impl fmt::Display for ActorKind {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.write_str(self.as_str())
157    }
158}
159
160/// Discriminant-only view of [`ActorKind`], bound to the `actor_kind` column
161/// in `governance_decisions`.
162///
163/// Binding a typed value couples the SQL CHECK allow-list to the enum at
164/// compile time; adding a variant without extending the constraint fails the
165/// build instead of silently rejecting rows at runtime.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
167#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
168#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT", rename_all = "snake_case"))]
169#[serde(rename_all = "snake_case")]
170pub enum ActorKindTag {
171    User,
172    Anonymous,
173    System,
174    Job,
175    Mcp,
176    Agent,
177}
178
179impl ActorKindTag {
180    #[must_use]
181    pub const fn as_str(self) -> &'static str {
182        match self {
183            Self::User => "user",
184            Self::Anonymous => "anonymous",
185            Self::System => "system",
186            Self::Job => "job",
187            Self::Mcp => "mcp",
188            Self::Agent => "agent",
189        }
190    }
191}
192
193impl fmt::Display for ActorKindTag {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        f.write_str(self.as_str())
196    }
197}