Skip to main content

turnframe_core/
turn.rs

1//! Turn input protocol (spec §9).
2//!
3//! A turn may carry free text, a structured interaction response, attachments
4//! and a server-issued origin reference **at the same time**. Mutual exclusion
5//! between text and a card reply is forbidden by design: a user may click
6//! "Confirm" and ask a question in one message.
7
8use indexmap::IndexMap;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::error::InvalidInputError;
13use crate::hash::Digest;
14use crate::ids::{
15    AccountId, AttachmentId, CaseRevision, ConversationId, InteractionId, OptionId, OriginToken,
16    TurnId, UserId,
17};
18use crate::locale::Locale;
19
20/// The authenticated actor of a turn, supplied by the application's
21/// authentication middleware. Trusted after authentication (spec §25.1).
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ActorContext {
24    /// Tenant the actor belongs to. Every lookup is scoped by it.
25    pub account_id: AccountId,
26    /// The user within the account.
27    pub user_id: UserId,
28    /// Application-defined roles.
29    #[serde(default)]
30    pub roles: Vec<String>,
31    /// Application-defined attributes (never shown to the model by the core).
32    #[serde(default)]
33    pub attributes: IndexMap<String, serde_json::Value>,
34}
35
36impl ActorContext {
37    /// Builds an actor with no roles or attributes.
38    #[must_use]
39    pub fn new(account_id: impl Into<AccountId>, user_id: impl Into<UserId>) -> Self {
40        Self {
41            account_id: account_id.into(),
42            user_id: user_id.into(),
43            roles: Vec::new(),
44            attributes: IndexMap::new(),
45        }
46    }
47
48    /// Adds a role.
49    #[must_use]
50    pub fn with_role(mut self, role: impl Into<String>) -> Self {
51        self.roles.push(role.into());
52        self
53    }
54
55    /// Returns `true` when the actor holds `role`.
56    #[must_use]
57    pub fn has_role(&self, role: &str) -> bool {
58        self.roles.iter().any(|r| r == role)
59    }
60}
61
62/// Reference to an attachment uploaded with the turn. The content itself is
63/// fetched by the application; the core only tracks identity and metadata.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct AttachmentRef {
67    /// Identifier the application assigned to the upload.
68    pub attachment_id: AttachmentId,
69    /// IANA media type.
70    pub media_type: String,
71    /// Original file name, if any.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub filename: Option<String>,
74    /// Size in bytes, if known.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub size_bytes: Option<u64>,
77    /// Content digest, if computed.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub digest: Option<Digest>,
80}
81
82/// The bytes of one attachment, as the application holds them.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct AttachmentContent {
85    /// IANA media type of what `bytes` are.
86    ///
87    /// Stated again rather than taken from the [`AttachmentRef`], because the
88    /// application is the one that knows: a file uploaded as
89    /// `application/octet-stream` may be a PNG, and only whoever stored it can
90    /// say so.
91    pub media_type: String,
92    /// The raw bytes. Encoding for a provider happens downstream.
93    pub bytes: Vec<u8>,
94}
95
96/// Why an attachment could not be fetched.
97///
98/// None of these ends a turn. A file the model cannot be shown is a turn that
99/// answers with less, not a turn that fails: the user has just done the work of
100/// taking a photograph, and losing their question as well is the worse outcome.
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102#[non_exhaustive]
103pub enum AttachmentError {
104    /// The application no longer holds the bytes.
105    ///
106    /// Ordinary rather than exceptional: an application may keep an attachment
107    /// for one turn and never write it down, which is a legitimate product
108    /// decision and the reason this is a port rather than a store.
109    #[error("attachment {attachment_id} is no longer held")]
110    Gone {
111        /// The attachment.
112        attachment_id: AttachmentId,
113    },
114    /// The actor may not read it.
115    #[error("attachment {attachment_id} is not readable by this actor")]
116    Unauthorized {
117        /// The attachment.
118        attachment_id: AttachmentId,
119    },
120    /// Fetching timed out.
121    #[error("fetching attachment {attachment_id} timed out")]
122    Timeout {
123        /// The attachment.
124        attachment_id: AttachmentId,
125    },
126    /// Anything else the application wants named.
127    #[error("attachment {attachment_id} failed: {code}")]
128    Other {
129        /// The attachment.
130        attachment_id: AttachmentId,
131        /// Stable code, for a dashboard rather than for a user.
132        code: String,
133    },
134}
135
136/// Where the bytes of a turn's attachments come from.
137///
138/// # Why this is a port and not a store
139///
140/// [`AttachmentRef`] carries identity and metadata and says plainly that the
141/// content is fetched by the application. That is deliberate: where a file
142/// lives, how long it lives and who may read it are product decisions, and at
143/// least one adopter's answer is that the bytes exist for one turn and are never
144/// written down. A library that stored them would be wrong for that deployment
145/// and could not be made right by configuration.
146///
147/// # What it is for
148///
149/// Attachments already travel end to end at the *plan* level: a turn carries
150/// them, the schema offers `attachment_extraction` as evidence, and an act can
151/// name a file so an application's own extractor fetches the bytes and rewrites
152/// the command — which is how a document's numbers reach a record without
153/// passing through a model at all. That half needs nothing from here.
154///
155/// The other half is the model **seeing** the file. Without it a turn carrying a
156/// photograph reaches the model as a sentence mentioning one, so a domain
157/// extractor that returns nothing — a receipt photographed at an angle, a scan
158/// of a scan, a document that is not what the prompt asks for — has no fallback
159/// but "I cannot read this", on the turn where the user has just done the work
160/// of taking the picture.
161///
162/// # What the runtime does with a failure
163///
164/// Nothing that costs the turn. The file is absent from the request, the user is
165/// told which ones were left out, and the writing stage is told too, so the
166/// prose cannot answer about a document nobody looked at.
167#[async_trait::async_trait]
168pub trait AttachmentSource: Send + Sync {
169    /// The bytes of one attachment of `turn_id`.
170    ///
171    /// The whole [`AttachmentRef`] is passed rather than only its identifier,
172    /// because an implementation usually wants the media type or the size it
173    /// already declared, and asking it to look them up again would be the
174    /// library making a call cheaper for itself.
175    async fn fetch(
176        &self,
177        turn_id: &TurnId,
178        attachment: &AttachmentRef,
179    ) -> Result<AttachmentContent, AttachmentError>;
180}
181
182/// A server-validated origin reference (spec §12.4): the UI surface that knows
183/// the exact record the user is looking at names it, so the model does not have
184/// to rediscover it from prose.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
186#[serde(deny_unknown_fields)]
187pub struct OriginRef {
188    /// Opaque token issued by the server for the record.
189    pub origin_token: OriginToken,
190    /// Optional signature the runtime verifies before trusting the token.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub signature: Option<String>,
193    /// Which UI surface produced the token (e.g. `"trip_detail"`).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub surface: Option<String>,
196}
197
198/// A structured reply to a persisted interaction (spec §9).
199///
200/// The client sends identifiers only; the server derives the meaning from the
201/// stored option (I7). `freeform_input` is accepted only when the stored option
202/// permits it.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct InteractionResponse {
206    /// The interaction being answered.
207    pub interaction_id: InteractionId,
208    /// The stored option chosen.
209    pub option_id: OptionId,
210    /// Case revision the card was rendered against.
211    pub expected_case_revision: CaseRevision,
212    /// Free-form value; only valid when the stored option explicitly permits it.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub freeform_input: Option<String>,
215}
216
217/// One user turn as accepted by the runtime (spec §9).
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct TurnInput {
221    /// Identifier of this turn.
222    pub turn_id: TurnId,
223    /// Conversation the turn belongs to.
224    pub conversation_id: ConversationId,
225    /// Authenticated actor.
226    pub actor: ActorContext,
227    /// Free text, if any.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub text: Option<String>,
230    /// Structured card reply, if any. May coexist with `text`.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub interaction_response: Option<InteractionResponse>,
233    /// Attachments supplied with the turn.
234    #[serde(default)]
235    pub attachments: Vec<AttachmentRef>,
236    /// Server-validated origin, if the UI supplied one.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub origin: Option<OriginRef>,
239    /// Locale of the user.
240    pub locale: Locale,
241    /// The effort this turn runs at, forced by the application; `None` takes the
242    /// configured default.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub effort: Option<crate::effort::Effort>,
245}
246
247/// Size limits on one turn's input, checked before anything is interpreted.
248///
249/// Both are `None` by default, which means unlimited. What counts as an
250/// oversized message depends on the product — a chat box and a document upload
251/// are not the same thing — so the library ships no answer and refuses nothing
252/// until a deployment says what it wants refused.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[non_exhaustive]
255pub struct TurnLimits {
256    /// Maximum length of the user's text in bytes, or `None` for no limit.
257    pub max_text_bytes: Option<usize>,
258    /// Maximum number of attachments, or `None` for no limit.
259    pub max_attachments: Option<usize>,
260}
261
262impl TurnLimits {
263    /// No limits at all, which is what ships.
264    #[must_use]
265    pub const fn conservative() -> Self {
266        Self {
267            max_text_bytes: None,
268            max_attachments: None,
269        }
270    }
271
272    /// Returns a copy with another text limit.
273    #[must_use]
274    pub const fn with_max_text_bytes(mut self, max_text_bytes: Option<usize>) -> Self {
275        self.max_text_bytes = max_text_bytes;
276        self
277    }
278
279    /// Returns a copy with another attachment limit.
280    #[must_use]
281    pub const fn with_max_attachments(mut self, max_attachments: Option<usize>) -> Self {
282        self.max_attachments = max_attachments;
283        self
284    }
285}
286
287impl Default for TurnLimits {
288    fn default() -> Self {
289        Self::conservative()
290    }
291}
292
293impl TurnInput {
294    /// Checks the structural rules of a turn against [`TurnLimits::conservative`].
295    ///
296    /// See [`Self::validate_shape_within`].
297    pub fn validate_shape(&self) -> Result<(), InvalidInputError> {
298        self.validate_shape_within(&TurnLimits::conservative())
299    }
300
301    /// Checks the structural rules of a turn: at least one of text,
302    /// interaction response or attachment must be present, the locale and the
303    /// account must be non-empty, and the text and attachments must fit
304    /// `limits`.
305    ///
306    /// Text and interaction response may coexist; this never rejects that.
307    pub fn validate_shape_within(&self, limits: &TurnLimits) -> Result<(), InvalidInputError> {
308        if !self.has_text() && self.interaction_response.is_none() && self.attachments.is_empty() {
309            return Err(InvalidInputError::EmptyTurn);
310        }
311        if self.locale.as_str().trim().is_empty() {
312            return Err(InvalidInputError::EmptyLocale);
313        }
314        if self.actor.account_id.is_empty() {
315            return Err(InvalidInputError::EmptyAccount);
316        }
317        if let Some(max_bytes) = limits.max_text_bytes
318            && self.text.as_ref().is_some_and(|t| t.len() > max_bytes)
319        {
320            return Err(InvalidInputError::TextTooLong { max_bytes });
321        }
322        if let Some(max) = limits.max_attachments
323            && self.attachments.len() > max
324        {
325            return Err(InvalidInputError::TooManyAttachments { max });
326        }
327        Ok(())
328    }
329
330    /// Returns `true` when the turn carries non-blank text.
331    #[must_use]
332    pub fn has_text(&self) -> bool {
333        self.text.as_deref().is_some_and(|t| !t.trim().is_empty())
334    }
335
336    /// Returns `true` when the turn is a pure card click: an interaction
337    /// response with no text and no attachments. Such turns need no model call
338    /// (spec §9).
339    #[must_use]
340    pub fn is_button_only(&self) -> bool {
341        self.interaction_response.is_some() && !self.has_text() && self.attachments.is_empty()
342    }
343
344    /// Convenience accessor for the account.
345    #[must_use]
346    pub fn account_id(&self) -> &AccountId {
347        &self.actor.account_id
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn base() -> TurnInput {
356        TurnInput {
357            turn_id: TurnId::nil(),
358            conversation_id: ConversationId::nil(),
359            actor: ActorContext::new("acct", "user"),
360            text: None,
361            interaction_response: None,
362            attachments: Vec::new(),
363            origin: None,
364            locale: Locale::from("it-IT"),
365            effort: None,
366        }
367    }
368
369    #[test]
370    fn empty_turn_is_rejected() {
371        assert_eq!(base().validate_shape(), Err(InvalidInputError::EmptyTurn));
372        let mut blank = base();
373        blank.text = Some("   ".into());
374        assert_eq!(blank.validate_shape(), Err(InvalidInputError::EmptyTurn));
375    }
376
377    #[test]
378    fn text_and_interaction_response_coexist() {
379        let mut turn = base();
380        turn.text = Some("confirm and tell me why".into());
381        turn.interaction_response = Some(InteractionResponse {
382            interaction_id: InteractionId::nil(),
383            option_id: OptionId::from("confirm"),
384            expected_case_revision: CaseRevision(12),
385            freeform_input: None,
386        });
387        assert_eq!(turn.validate_shape(), Ok(()));
388        assert!(!turn.is_button_only());
389        turn.text = None;
390        assert!(turn.is_button_only());
391    }
392
393    #[test]
394    fn limits_are_enforced_where_they_are_declared() {
395        let mut long = base();
396        long.text = Some("x".repeat(64));
397        assert_eq!(
398            long.validate_shape_within(&TurnLimits::conservative().with_max_text_bytes(Some(32))),
399            Err(InvalidInputError::TextTooLong { max_bytes: 32 })
400        );
401        assert_eq!(long.validate_shape(), Ok(()));
402        let mut many = base();
403        many.text = Some("hi".into());
404        many.attachments = (0..3)
405            .map(|n| AttachmentRef {
406                attachment_id: AttachmentId::from(format!("a{n}")),
407                media_type: "application/pdf".into(),
408                filename: None,
409                size_bytes: None,
410                digest: None,
411            })
412            .collect();
413        assert_eq!(
414            many.validate_shape_within(&TurnLimits::conservative().with_max_attachments(Some(2))),
415            Err(InvalidInputError::TooManyAttachments { max: 2 })
416        );
417        assert_eq!(many.validate_shape(), Ok(()));
418    }
419
420    #[test]
421    fn client_payload_rejects_unknown_fields() {
422        let json = r#"{"interaction_id":"00000000-0000-0000-0000-000000000000","option_id":"a","expected_case_revision":1,"value":"evil"}"#;
423        assert!(serde_json::from_str::<InteractionResponse>(json).is_err());
424    }
425}