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}