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//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14
15use crate::{AgentId, UserId};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Actor {
19    pub user_id: UserId,
20    pub kind: ActorKind,
21}
22
23impl Actor {
24    #[must_use]
25    pub const fn user(user_id: UserId) -> Self {
26        Self {
27            user_id,
28            kind: ActorKind::User,
29        }
30    }
31
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    #[must_use]
41    pub const fn system(user_id: UserId) -> Self {
42        Self {
43            user_id,
44            kind: ActorKind::System,
45        }
46    }
47
48    #[must_use]
49    pub fn job(user_id: UserId, job_name: impl Into<String>) -> Self {
50        Self {
51            user_id,
52            kind: ActorKind::Job {
53                job_name: job_name.into(),
54            },
55        }
56    }
57
58    #[must_use]
59    pub fn mcp(user_id: UserId, server_name: impl Into<String>) -> Self {
60        Self {
61            user_id,
62            kind: ActorKind::Mcp {
63                server_name: server_name.into(),
64            },
65        }
66    }
67
68    #[must_use]
69    pub const fn agent(user_id: UserId, agent_id: AgentId) -> Self {
70        Self {
71            user_id,
72            kind: ActorKind::Agent { agent_id },
73        }
74    }
75
76    #[must_use]
77    pub fn audit_columns(&self) -> (&str, &str) {
78        (self.kind.as_str(), self.kind.actor_id(&self.user_id))
79    }
80
81    #[must_use]
82    pub fn from_tool_name(user_id: UserId, agent_id: Option<&AgentId>, tool_name: &str) -> Self {
83        if let Some(rest) = tool_name.strip_prefix("mcp__")
84            && let Some(server) = rest.split("__").next()
85            && !server.is_empty()
86        {
87            return Self::mcp(user_id, server);
88        }
89        match agent_id {
90            Some(id) if !id.as_str().is_empty() => Self::agent(user_id, id.clone()),
91            _ => Self::user(user_id),
92        }
93    }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98pub enum ActorKind {
99    User,
100    Anonymous,
101    System,
102    Job { job_name: String },
103    Mcp { server_name: String },
104    Agent { agent_id: AgentId },
105}
106
107impl ActorKind {
108    #[must_use]
109    pub const fn as_str(&self) -> &'static str {
110        match self {
111            Self::User => "user",
112            Self::Anonymous => "anonymous",
113            Self::System => "system",
114            Self::Job { .. } => "job",
115            Self::Mcp { .. } => "mcp",
116            Self::Agent { .. } => "agent",
117        }
118    }
119
120    #[must_use]
121    pub fn actor_id<'a>(&'a self, user_id: &'a UserId) -> &'a str {
122        match self {
123            Self::User | Self::Anonymous | Self::System => user_id.as_str(),
124            Self::Job { job_name } => job_name.as_str(),
125            Self::Mcp { server_name } => server_name.as_str(),
126            Self::Agent { agent_id } => agent_id.as_str(),
127        }
128    }
129}
130
131impl ActorKind {
132    #[must_use]
133    pub const fn tag(&self) -> ActorKindTag {
134        match self {
135            Self::User => ActorKindTag::User,
136            Self::Anonymous => ActorKindTag::Anonymous,
137            Self::System => ActorKindTag::System,
138            Self::Job { .. } => ActorKindTag::Job,
139            Self::Mcp { .. } => ActorKindTag::Mcp,
140            Self::Agent { .. } => ActorKindTag::Agent,
141        }
142    }
143}
144
145impl fmt::Display for ActorKind {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(self.as_str())
148    }
149}
150
151/// Discriminant-only view of [`ActorKind`], bound to the `actor_kind` column
152/// in `governance_decisions`.
153///
154/// Binding a typed value couples the SQL CHECK allow-list to the enum at
155/// compile time; adding a variant without extending the constraint fails the
156/// build instead of silently rejecting rows at runtime.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
158#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
159#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT", rename_all = "snake_case"))]
160#[serde(rename_all = "snake_case")]
161pub enum ActorKindTag {
162    User,
163    Anonymous,
164    System,
165    Job,
166    Mcp,
167    Agent,
168}
169
170impl ActorKindTag {
171    #[must_use]
172    pub const fn as_str(self) -> &'static str {
173        match self {
174            Self::User => "user",
175            Self::Anonymous => "anonymous",
176            Self::System => "system",
177            Self::Job => "job",
178            Self::Mcp => "mcp",
179            Self::Agent => "agent",
180        }
181    }
182}
183
184impl fmt::Display for ActorKindTag {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        f.write_str(self.as_str())
187    }
188}