Skip to main content

systemprompt_identifiers/
context.rs

1//! Execution-context identifier — UUID v4 only.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use crate::error::IdValidationError;
7use crate::{EvalRunId, GatewayConversationId, SessionId, TaskId};
8
9crate::define_id!(ContextId, validated, schema, validate_uuid_v4);
10
11fn validate_uuid_v4(s: &str) -> Result<(), IdValidationError> {
12    uuid::Uuid::parse_str(s).map_err(|e| IdValidationError::invalid("ContextId", e.to_string()))?;
13    Ok(())
14}
15
16const GATEWAY_CONVERSATION_NAMESPACE: uuid::Uuid =
17    uuid::Uuid::from_u128(0x993f_3f2c_f4d9_463b_853a_d3f0_3e19_0898);
18
19const MESSAGING_NAMESPACE: uuid::Uuid =
20    uuid::Uuid::from_u128(0x6b1d_2a7e_9c84_4f31_b5e0_71a2_4d8c_3f06);
21
22const SESSION_NAMESPACE: uuid::Uuid =
23    uuid::Uuid::from_u128(0x4c1e_8b02_7a63_4d51_9f2c_0e58_a7d4_31bb);
24
25const EVALUATION_NAMESPACE: uuid::Uuid =
26    uuid::Uuid::from_u128(0x7f3a_c2d1_5b09_4e87_a6f4_2c91_d05e_88a3);
27
28const CLI_PROBE_NAMESPACE: uuid::Uuid =
29    uuid::Uuid::from_u128(0x2d84_9f60_1c3b_4a72_8e15_b7d0_63f9_a541);
30
31const MCP_VALIDATION_NAMESPACE: uuid::Uuid =
32    uuid::Uuid::from_u128(0x91b6_04ce_7d2f_4380_b8a9_5e16_c74d_2f08);
33
34const TASK_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x5ae0_37b9_8f14_4c26_9d7b_e842_06c1_fd35);
35
36const LEGACY_CONTEXT_UUID: &str = "00000000-0000-0000-0000-4c4547414359";
37
38impl ContextId {
39    pub fn generate() -> Self {
40        Self::new(uuid::Uuid::new_v4().to_string())
41    }
42
43    /// Mint a deterministic `ContextId` from a `GatewayConversationId`.
44    ///
45    /// Same gateway-conversation id always produces the same `ContextId`, so
46    /// the gateway boundary can satisfy the "every conversation has a UUID
47    /// `ContextId`" data-integrity invariant without trusting the upstream
48    /// LLM client's `x-context-id` header (which carries client-specific
49    /// non-UUID identifiers).
50    #[must_use]
51    pub fn derived_from_gateway_conversation(gw: &GatewayConversationId) -> Self {
52        Self::new(
53            uuid::Uuid::new_v5(&GATEWAY_CONVERSATION_NAMESPACE, gw.as_str().as_bytes()).to_string(),
54        )
55    }
56
57    /// Mint a deterministic `ContextId` for a chat-platform conversation.
58    ///
59    /// The same `(platform, org, channel)` triple — e.g.
60    /// `("slack", workspace_id, channel_id)` or
61    /// `("teams", tenant_id, conversation_id)` — always produces the same
62    /// `ContextId`, so the messaging dispatch boundary satisfies the "every
63    /// conversation has a UUID `ContextId`" invariant without a channel→context
64    /// mapping table.
65    #[must_use]
66    pub fn derived_from_messaging(platform: &str, org: &str, channel: &str) -> Self {
67        let key = format!("{platform}:{org}:{channel}");
68        Self::new(uuid::Uuid::new_v5(&MESSAGING_NAMESPACE, key.as_bytes()).to_string())
69    }
70
71    /// Mint a deterministic `ContextId` from a `SessionId`.
72    ///
73    /// Same session always produces the same `ContextId`, so session-scoped
74    /// boundaries with no conversation of their own (anonymous sessions,
75    /// hook-triggered inference such as session summaries, header fallbacks)
76    /// satisfy the "every AI request belongs to exactly one real context"
77    /// invariant without a session→context mapping table.
78    #[must_use]
79    pub fn derived_from_session(session_id: &SessionId) -> Self {
80        Self::new(
81            uuid::Uuid::new_v5(&SESSION_NAMESPACE, session_id.as_str().as_bytes()).to_string(),
82        )
83    }
84
85    /// Mint a deterministic `ContextId` from an evaluation run id.
86    ///
87    /// Same run always produces the same `ContextId`, so every judge and
88    /// replay request of one evaluation run lands in one context.
89    #[must_use]
90    pub fn derived_from_evaluation_run(run_id: &EvalRunId) -> Self {
91        Self::new(uuid::Uuid::new_v5(&EVALUATION_NAMESPACE, run_id.as_str().as_bytes()).to_string())
92    }
93
94    /// Mint a deterministic `ContextId` for a CLI probe of an MCP server.
95    ///
96    /// Same server name always produces the same `ContextId`, so repeated
97    /// diagnostic probes of one server share one context.
98    #[must_use]
99    pub fn derived_from_cli_probe(server_name: &str) -> Self {
100        Self::new(uuid::Uuid::new_v5(&CLI_PROBE_NAMESPACE, server_name.as_bytes()).to_string())
101    }
102
103    /// Mint a deterministic `ContextId` for MCP service validation.
104    ///
105    /// Same service name always produces the same `ContextId`, so repeated
106    /// validation passes of one service share one context.
107    #[must_use]
108    pub fn derived_from_mcp_validation(service_name: &str) -> Self {
109        Self::new(
110            uuid::Uuid::new_v5(&MCP_VALIDATION_NAMESPACE, service_name.as_bytes()).to_string(),
111        )
112    }
113
114    /// Mint a deterministic `ContextId` from a `TaskId`.
115    ///
116    /// Fallback for task-scoped boundaries when the task's real context row
117    /// cannot be resolved; same task always produces the same `ContextId`.
118    #[must_use]
119    pub fn derived_from_task(task_id: &TaskId) -> Self {
120        Self::new(uuid::Uuid::new_v5(&TASK_NAMESPACE, task_id.as_str().as_bytes()).to_string())
121    }
122
123    /// The fixed context that absorbs pre-invariant historical rows.
124    ///
125    /// Rows written before context ids became mandatory were backfilled to
126    /// this constant; read paths that meet an unparseable stored id also
127    /// resolve here rather than fabricating a fresh id.
128    #[must_use]
129    pub fn legacy() -> Self {
130        Self::new(LEGACY_CONTEXT_UUID)
131    }
132}