Skip to main content

systemprompt_models/origin/
mod.rs

1//! Where a persisted AI request came from: the client that produced it, the
2//! inbound wire protocol it spoke, and how strongly the client is evidenced.
3//!
4//! All three halves are closed enums so `ai_requests.client_kind`,
5//! `ai_requests.wire_protocol` and `ai_requests.client_attestation` are
6//! CHECK-constrained columns, never free text. This module is the normative
7//! specification of client attribution; the hosted gateway documentation
8//! restates it for operators.
9//!
10//! # Evidence tiers
11//!
12//! [`classify`] answers "which client sent this request?" by walking a ladder
13//! of evidence, strongest first. The winning tier sets `client_kind` and
14//! `client_attestation`; everything the wire carried is kept in
15//! [`ClientEvidence`] so a classification can be audited or corrected later.
16//!
17//! | tier | header / signal | trust |
18//! |------|-----------------|-------|
19//! | `host-token` | bridge principal with `x-systemprompt-client-attestation: host-token`; the bridge verified a per-host HMAC token on its loopback and stamped `x-systemprompt-client` itself | cryptographic on the device, channel-bound to the bridge |
20//! | `bridge-secret` | bridge principal with `x-systemprompt-client-attestation: bridge-secret`; the caller presented the raw loopback secret, so the host is taken from the lower tiers | channel verified, host not |
21//! | `declared` | `x-systemprompt-client: <kind>` from any principal (or passed through on the secret path) | client-asserted, closed vocabulary |
22//! | `native-marker` | a structural marker of one harness in the body ([`NativeMarker`]): Claude Code's billing entrypoint or `metadata.user_id` grammars, Codex turn metadata | structural, unforged in practice |
23//! | `user-agent` | the exact first product token of `User-Agent` ([`ua_product`]) | weakest tier kept |
24//! | `none` | nothing matched; `client_kind` is `other` | honest fallback |
25//!
26//! `internal` and `unknown` are never produced from live gateway traffic:
27//! `internal` pairs with server-side producers and `unknown` with rows written
28//! before attribution existed.
29//!
30//! A conflict between tiers (a host token naming `opencode` under a
31//! `claude-cli` User-Agent) is never rejected: the strongest tier wins and the
32//! evidence row records the rest. Two inputs are rejected with `400` instead:
33//! an `x-systemprompt-client` value outside the vocabulary, and an
34//! `x-systemprompt-client-attestation` header from anything but the bridge.
35//!
36//! The one wire vocabulary is [`ClientKind::as_str`]. A bridge host
37//! ([`HostKind`]) names a packaging of a product; the wire cannot tell a
38//! desktop app from its CLI, so both packagings map onto one `ClientKind`
39//! through [`From<HostKind>`], and [`ClientKind::hosts`] lists the packagings
40//! a wire value may stand for.
41//!
42//! Copyright (c) systemprompt.io — Business Source License 1.1.
43//! See <https://systemprompt.io> for licensing details.
44
45mod attestation;
46mod classify;
47mod evidence;
48mod harness;
49mod memory;
50
51pub use attestation::{ClientAttestation, NativeMarker};
52pub use classify::{
53    ClassificationInput, ClassificationRejection, Classified, StainlessHeaders, classify,
54    native_marker, ua_product,
55};
56pub use evidence::ClientEvidence;
57pub use memory::MemoryPreference;
58
59use serde::{Deserialize, Serialize};
60
61use crate::bridge::host::{HostAppKind, HostKind, HostProduct};
62
63/// The client that produced an AI request.
64///
65/// `Other` is a real HTTP caller the gateway could not name (a raw SDK, curl,
66/// a chat UI); its evidence row still records what it presented. `Internal`
67/// is a request the server made for itself with no HTTP ingress. `Unknown`
68/// exists only for rows written before attribution was recorded and for an
69/// old binary inserting during a deploy window; live code never constructs it.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum ClientKind {
73    ClaudeCode,
74    ClaudeDesktop,
75    Codex,
76    #[serde(rename = "opencode")]
77    OpenCode,
78    Hermes,
79    Pi,
80    Other,
81    Internal,
82    Unknown,
83}
84
85/// The protocol a request arrived on. `Internal` pairs with
86/// [`ClientKind::Internal`]; `Unknown` pairs with [`ClientKind::Unknown`].
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88pub enum InboundWireProtocol {
89    #[serde(rename = "anthropic.messages")]
90    AnthropicMessages,
91    #[serde(rename = "openai.chat")]
92    OpenAiChat,
93    #[serde(rename = "openai.responses")]
94    OpenAiResponses,
95    #[serde(rename = "internal")]
96    Internal,
97    #[serde(rename = "unknown")]
98    Unknown,
99}
100
101/// Client, wire protocol and attestation of one request, carried together so
102/// a producer cannot record one without the others.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104pub struct RequestOrigin {
105    pub client: ClientKind,
106    pub wire: InboundWireProtocol,
107    pub attestation: ClientAttestation,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
111pub enum OriginParseError {
112    #[error("unknown client kind: {0}")]
113    ClientKind(String),
114    #[error("unknown inbound wire protocol: {0}")]
115    WireProtocol(String),
116    #[error("unknown client attestation: {0}")]
117    Attestation(String),
118    #[error("unknown native marker: {0}")]
119    NativeMarker(String),
120    #[error("client kind {0} is not an evaluator harness")]
121    NotAHarness(&'static str),
122}
123
124impl ClientKind {
125    pub const ALL: [Self; 9] = [
126        Self::ClaudeCode,
127        Self::ClaudeDesktop,
128        Self::Codex,
129        Self::OpenCode,
130        Self::Hermes,
131        Self::Pi,
132        Self::Other,
133        Self::Internal,
134        Self::Unknown,
135    ];
136
137    // Why: the vocabulary a client may declare excludes the two server-only
138    // values; `other` is declarable so a client can say "nothing you know".
139    pub const DECLARABLE: [Self; 7] = [
140        Self::ClaudeCode,
141        Self::ClaudeDesktop,
142        Self::Codex,
143        Self::OpenCode,
144        Self::Hermes,
145        Self::Pi,
146        Self::Other,
147    ];
148
149    #[must_use]
150    pub const fn as_str(self) -> &'static str {
151        match self {
152            Self::ClaudeCode => "claude-code",
153            Self::ClaudeDesktop => "claude-desktop",
154            Self::Codex => "codex",
155            Self::OpenCode => "opencode",
156            Self::Hermes => "hermes",
157            Self::Pi => "pi",
158            Self::Other => "other",
159            Self::Internal => "internal",
160            Self::Unknown => "unknown",
161        }
162    }
163
164    #[must_use]
165    pub const fn label(self) -> &'static str {
166        match self {
167            Self::ClaudeCode => "Claude Code",
168            Self::ClaudeDesktop => "Claude Desktop",
169            Self::Codex => "Codex",
170            Self::OpenCode => "OpenCode",
171            Self::Hermes => "Hermes",
172            Self::Pi => "Pi",
173            Self::Other => "API client",
174            Self::Internal => "Internal",
175            Self::Unknown => "Unknown",
176        }
177    }
178
179    pub fn parse(value: &str) -> Result<Self, OriginParseError> {
180        Self::ALL
181            .into_iter()
182            .find(|kind| kind.as_str() == value)
183            .ok_or_else(|| OriginParseError::ClientKind(value.to_owned()))
184    }
185
186    pub fn hosts(self) -> impl Iterator<Item = HostKind> {
187        HostKind::ALL
188            .into_iter()
189            .filter(move |host| Self::from(*host) == self)
190    }
191
192    #[must_use]
193    pub const fn product(self) -> Option<HostProduct> {
194        match self {
195            Self::ClaudeCode | Self::ClaudeDesktop => Some(HostProduct::Claude),
196            Self::Codex => Some(HostProduct::Codex),
197            Self::OpenCode => Some(HostProduct::OpenCode),
198            Self::Hermes => Some(HostProduct::Hermes),
199            Self::Pi | Self::Other | Self::Internal | Self::Unknown => None,
200        }
201    }
202
203    // Why: only the exact, lower-cased first product token is consulted —
204    // `contains` would let any client name a harness by mentioning it.
205    #[must_use]
206    pub fn from_ua_product(product: &str) -> Option<Self> {
207        match product {
208            "claude-cli" | "claude-code" => Some(Self::ClaudeCode),
209            "claude-desktop" => Some(Self::ClaudeDesktop),
210            "codex_cli_rs" => Some(Self::Codex),
211            "opencode" => Some(Self::OpenCode),
212            "hermes-agent" => Some(Self::Hermes),
213            _ => None,
214        }
215    }
216
217    // Why: Claude Desktop runs the Claude Code runtime, so its requests wear
218    // the `claude-cli` User-Agent and the CLI's metadata grammar; a signal
219    // naming either packaging of the Claude product is agreement, not a
220    // conflict.
221    #[must_use]
222    pub const fn same_runtime(self, other: Self) -> bool {
223        (self as u8) == (other as u8)
224            || matches!(
225                (self.product(), other.product()),
226                (Some(HostProduct::Claude), Some(HostProduct::Claude))
227            )
228    }
229}
230
231impl From<HostKind> for ClientKind {
232    fn from(host: HostKind) -> Self {
233        match host.product() {
234            HostProduct::Claude => match host.kind() {
235                HostAppKind::DesktopApp => Self::ClaudeDesktop,
236                HostAppKind::CliTool => Self::ClaudeCode,
237            },
238            HostProduct::Codex => Self::Codex,
239            HostProduct::OpenCode => Self::OpenCode,
240            HostProduct::Hermes => Self::Hermes,
241        }
242    }
243}
244
245impl InboundWireProtocol {
246    pub const ALL: [Self; 5] = [
247        Self::AnthropicMessages,
248        Self::OpenAiChat,
249        Self::OpenAiResponses,
250        Self::Internal,
251        Self::Unknown,
252    ];
253
254    #[must_use]
255    pub const fn as_str(self) -> &'static str {
256        match self {
257            Self::AnthropicMessages => "anthropic.messages",
258            Self::OpenAiChat => "openai.chat",
259            Self::OpenAiResponses => "openai.responses",
260            Self::Internal => "internal",
261            Self::Unknown => "unknown",
262        }
263    }
264
265    pub fn parse(value: &str) -> Result<Self, OriginParseError> {
266        Self::ALL
267            .into_iter()
268            .find(|wire| wire.as_str() == value)
269            .ok_or_else(|| OriginParseError::WireProtocol(value.to_owned()))
270    }
271}
272
273impl RequestOrigin {
274    pub const INTERNAL: Self = Self {
275        client: ClientKind::Internal,
276        wire: InboundWireProtocol::Internal,
277        attestation: ClientAttestation::Internal,
278    };
279
280    #[must_use]
281    pub const fn gateway(
282        client: ClientKind,
283        wire: InboundWireProtocol,
284        attestation: ClientAttestation,
285    ) -> Self {
286        Self {
287            client,
288            wire,
289            attestation,
290        }
291    }
292}