Skip to main content

turnframe_core/
target.rs

1//! Deterministic target resolution (spec §12).
2//!
3//! The model never sees raw case identifiers. The runtime issues opaque
4//! [`TargetToken`]s per turn through a [`TargetTokenMap`] scoped to one account;
5//! the map is the only authority on what a token means. Unknown tokens and
6//! tokens of other tenants resolve identically to [`TargetResolution::Unauthorized`]
7//! so a guessed token reveals nothing (spec §25.4).
8
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11
12use crate::case::{CaseKey, CaseRef};
13use crate::command::CommandOrigin;
14use crate::hash::{Digest, digest_hex};
15use crate::ids::{AccountId, CaseRevision, OperationKey, TargetToken, TurnId, WorkflowKey};
16use crate::understanding::ActId;
17
18/// One authorized candidate offered to the model or to a selection card.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TargetCandidate {
21    /// Opaque token.
22    pub token: TargetToken,
23    /// The case behind it.
24    pub case_ref: CaseRef,
25    /// Server-authored human label (e.g. "Trip 12 - Ferri").
26    pub label: String,
27}
28
29/// Outcome of resolving a proposed target (spec §12.2). Only `Exact` may reach
30/// command compilation.
31///
32/// New outcomes are expected, so downstream matches need a wildcard arm.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35#[non_exhaustive]
36pub enum TargetResolution {
37    /// Exactly one authorized case.
38    Exact {
39        /// The case.
40        case_ref: CaseRef,
41    },
42    /// Several authorized cases; never pick one (I8).
43    Ambiguous {
44        /// The candidates for a selection interaction.
45        candidates: Vec<TargetCandidate>,
46    },
47    /// The token was issued this turn but the case no longer exists.
48    Missing,
49    /// Unknown token or another tenant's token.
50    Unauthorized,
51    /// The case moved past the revision the token was issued at.
52    Stale {
53        /// Reference at issue time.
54        case_ref: CaseRef,
55        /// Revision now.
56        current_revision: CaseRevision,
57    },
58}
59
60impl TargetResolution {
61    /// Returns the case when the resolution is exact.
62    #[must_use]
63    pub fn exact(&self) -> Option<&CaseRef> {
64        match self {
65            Self::Exact { case_ref } => Some(case_ref),
66            _ => None,
67        }
68    }
69
70    /// Returns `true` for `Exact`.
71    #[must_use]
72    pub fn is_exact(&self) -> bool {
73        matches!(self, Self::Exact { .. })
74    }
75}
76
77/// What kind of act was resolved.
78///
79/// It grows with [`ActAction`](crate::understanding::ActAction), so downstream
80/// matches need a wildcard arm.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "kind", rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum ResolvedActKind {
85    /// Apply a catalog operation.
86    ApplyOperation {
87        /// The operation.
88        operation: OperationKey,
89    },
90    /// Start a new case (the case reference carries revision zero).
91    StartWorkflow,
92}
93
94/// An understood act after exact target resolution, the input of
95/// [`crate::flow::WorkflowDefinition::compile_act`].
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ResolvedAct {
98    /// The act.
99    pub act: ActId,
100    /// What the act does.
101    pub kind: ResolvedActKind,
102    /// The exact case at the loaded revision.
103    pub case_ref: CaseRef,
104    /// Arguments, parsed and schema-validated by the reducer.
105    pub arguments: serde_json::Value,
106    /// Digest of the words and choices the act rests on.
107    pub evidence_digest: Digest,
108}
109
110impl ResolvedAct {
111    /// The operation named by the act, when any.
112    #[must_use]
113    pub fn operation(&self) -> Option<&OperationKey> {
114        match &self.kind {
115            ResolvedActKind::ApplyOperation { operation } => Some(operation),
116            ResolvedActKind::StartWorkflow => None,
117        }
118    }
119
120    /// The origin a low-risk command compiled from this act carries.
121    #[must_use]
122    pub fn direct_origin(&self) -> CommandOrigin {
123        CommandOrigin::DirectSafeUserAct {
124            evidence_digest: self.evidence_digest.clone(),
125        }
126    }
127}
128
129/// Server-side state of an issued token.
130///
131/// New states are expected, so downstream matches need a wildcard arm.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(tag = "kind", rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum TokenStatus {
136    /// Resolves to the case.
137    Authorized,
138    /// The case vanished after the token was issued.
139    Missing,
140    /// The case moved to another revision after the token was issued.
141    Stale {
142        /// Revision now.
143        current_revision: CaseRevision,
144    },
145}
146
147/// What a token maps to.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct TargetEntry {
150    /// The case at issue time.
151    pub case_ref: CaseRef,
152    /// Human label.
153    pub label: String,
154    /// Current status.
155    pub status: TokenStatus,
156}
157
158const TOKEN_DOMAIN: &str = "turnframe.target.v1";
159const TOKEN_PREFIX: &str = "t_";
160const TOKEN_MIN_HEX: usize = 12;
161
162/// Account-scoped map from opaque tokens to authorized cases for one turn.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct TargetTokenMap {
165    account_id: AccountId,
166    turn_id: TurnId,
167    entries: IndexMap<TargetToken, TargetEntry>,
168}
169
170impl TargetTokenMap {
171    /// Creates an empty map for one account and turn.
172    #[must_use]
173    pub fn new(account_id: AccountId, turn_id: TurnId) -> Self {
174        Self {
175            account_id,
176            turn_id,
177            entries: IndexMap::new(),
178        }
179    }
180
181    /// The account the map is scoped to.
182    #[must_use]
183    pub fn account_id(&self) -> &AccountId {
184        &self.account_id
185    }
186
187    /// The turn the tokens were issued for.
188    #[must_use]
189    pub fn turn_id(&self) -> &TurnId {
190        &self.turn_id
191    }
192
193    /// Issues (or returns the existing) token for a case.
194    ///
195    /// Tokens are derived deterministically from the account, turn and case so a
196    /// replayed turn reproduces the same catalog; they are opaque and carry no
197    /// database identifier. Re-issuing for a case already in the map updates the
198    /// label and revision and restores `Authorized`.
199    pub fn issue(&mut self, case_ref: CaseRef, label: impl Into<String>) -> TargetToken {
200        let key = case_ref.key();
201        if let Some((token, entry)) = self
202            .entries
203            .iter_mut()
204            .find(|(_, e)| e.case_ref.key() == key)
205        {
206            entry.case_ref = case_ref;
207            entry.label = label.into();
208            entry.status = TokenStatus::Authorized;
209            return token.clone();
210        }
211        let token = self.fresh_token(&key);
212        self.entries.insert(
213            token.clone(),
214            TargetEntry {
215                case_ref,
216                label: label.into(),
217                status: TokenStatus::Authorized,
218            },
219        );
220        token
221    }
222
223    fn fresh_token(&self, key: &CaseKey) -> TargetToken {
224        let material = format!(
225            "{TOKEN_DOMAIN}\u{0}{}\u{0}{}\u{0}{}\u{0}{}",
226            self.account_id, self.turn_id, key.workflow, key.case_id
227        );
228        let hex = digest_hex(material.as_bytes());
229        let mut len = TOKEN_MIN_HEX;
230        loop {
231            let candidate = TargetToken::from(format!("{TOKEN_PREFIX}{}", &hex[..len]));
232            if !self.entries.contains_key(&candidate) || len >= hex.len() {
233                return candidate;
234            }
235            len = (len + 4).min(hex.len());
236        }
237    }
238
239    /// Resolves a token for `account`.
240    ///
241    /// A different account, an unknown token and another tenant's token all
242    /// yield `Unauthorized`. `Missing` and `Stale` are reported only for tokens
243    /// issued in this map and marked as such.
244    #[must_use]
245    pub fn resolve(&self, account: &AccountId, token: &TargetToken) -> TargetResolution {
246        if account != &self.account_id {
247            return TargetResolution::Unauthorized;
248        }
249        match self.entries.get(token) {
250            None => TargetResolution::Unauthorized,
251            Some(entry) => match &entry.status {
252                TokenStatus::Authorized => TargetResolution::Exact {
253                    case_ref: entry.case_ref.clone(),
254                },
255                TokenStatus::Missing => TargetResolution::Missing,
256                TokenStatus::Stale { current_revision } => TargetResolution::Stale {
257                    case_ref: entry.case_ref.clone(),
258                    current_revision: *current_revision,
259                },
260            },
261        }
262    }
263
264    /// Marks a token's case as gone. Returns `false` when the token is unknown.
265    pub fn mark_missing(&mut self, token: &TargetToken) -> bool {
266        match self.entries.get_mut(token) {
267            Some(entry) => {
268                entry.status = TokenStatus::Missing;
269                true
270            }
271            None => false,
272        }
273    }
274
275    /// Marks a token's case as moved to `current_revision`. Returns `false` when
276    /// the token is unknown.
277    pub fn mark_stale(&mut self, token: &TargetToken, current_revision: CaseRevision) -> bool {
278        match self.entries.get_mut(token) {
279            Some(entry) => {
280                entry.status = TokenStatus::Stale { current_revision };
281                true
282            }
283            None => false,
284        }
285    }
286
287    /// The token issued for a case, if any.
288    #[must_use]
289    pub fn token_for(&self, key: &CaseKey) -> Option<&TargetToken> {
290        self.entries
291            .iter()
292            .find(|(_, e)| e.case_ref.key() == *key)
293            .map(|(token, _)| token)
294    }
295
296    /// Entry behind a token, without an account check. For the runtime that owns
297    /// the map; client-facing paths must use [`Self::resolve`].
298    #[must_use]
299    pub fn get(&self, token: &TargetToken) -> Option<&TargetEntry> {
300        self.entries.get(token)
301    }
302
303    /// All authorized candidates, for the interpretation catalog.
304    #[must_use]
305    pub fn candidates(&self) -> Vec<TargetCandidate> {
306        self.entries
307            .iter()
308            .filter(|(_, e)| e.status == TokenStatus::Authorized)
309            .map(|(token, e)| TargetCandidate {
310                token: token.clone(),
311                case_ref: e.case_ref.clone(),
312                label: e.label.clone(),
313            })
314            .collect()
315    }
316
317    /// Authorized candidates of one workflow, for resolving mentions.
318    #[must_use]
319    pub fn candidates_for(&self, workflow: &WorkflowKey) -> Vec<TargetCandidate> {
320        self.candidates()
321            .into_iter()
322            .filter(|c| &c.case_ref.workflow == workflow)
323            .collect()
324    }
325
326    /// Iterates all entries.
327    pub fn iter(&self) -> impl Iterator<Item = (&TargetToken, &TargetEntry)> {
328        self.entries.iter()
329    }
330
331    /// Number of tokens.
332    #[must_use]
333    pub fn len(&self) -> usize {
334        self.entries.len()
335    }
336
337    /// Returns `true` when no token was issued.
338    #[must_use]
339    pub fn is_empty(&self) -> bool {
340        self.entries.is_empty()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn map() -> TargetTokenMap {
349        TargetTokenMap::new(AccountId::from("acct"), TurnId::nil())
350    }
351
352    #[test]
353    fn issue_is_deterministic_and_opaque() {
354        let mut a = map();
355        let mut b = map();
356        let case = CaseRef::new("trip", "trip-42", CaseRevision(3));
357        let t1 = a.issue(case.clone(), "Trip 42");
358        let t2 = b.issue(case.clone(), "Trip 42");
359        assert_eq!(t1, t2);
360        assert!(t1.as_str().starts_with("t_"));
361        assert!(!t1.as_str().contains("trip-42"));
362        assert_eq!(a.issue(case.with_revision(CaseRevision(4)), "Trip 42"), t1);
363        assert_eq!(a.len(), 1);
364        assert_eq!(
365            a.get(&t1).unwrap().case_ref.expected_revision,
366            CaseRevision(4)
367        );
368    }
369
370    #[test]
371    fn tokens_are_bound_to_their_account_and_turn() {
372        let case = CaseRef::new("trip", "trip-42", CaseRevision(3));
373        let mut mine = map();
374        let token = mine.issue(case.clone(), "Trip 42");
375        let mut other_account = TargetTokenMap::new(AccountId::from("other"), TurnId::nil());
376        let mut other_turn = TargetTokenMap::new(AccountId::from("acct"), TurnId::new());
377        assert_ne!(
378            token,
379            other_account.issue(case.clone(), "Trip 42"),
380            "another tenant never gets the same token for the same case"
381        );
382        assert_ne!(
383            token,
384            other_turn.issue(case, "Trip 42"),
385            "a token issued in another turn is a different token"
386        );
387        // A token from one map means nothing in another.
388        assert_eq!(
389            other_turn.resolve(&AccountId::from("acct"), &token),
390            TargetResolution::Unauthorized
391        );
392    }
393
394    #[test]
395    fn unknown_and_foreign_tokens_are_indistinguishable() {
396        let mut m = map();
397        let token = m.issue(CaseRef::new("trip", "i1", CaseRevision(1)), "l");
398        assert_eq!(
399            m.resolve(&AccountId::from("other"), &token),
400            TargetResolution::Unauthorized
401        );
402        assert_eq!(
403            m.resolve(
404                &AccountId::from("acct"),
405                &TargetToken::from("t_deadbeef0000")
406            ),
407            TargetResolution::Unauthorized
408        );
409        assert!(m.resolve(&AccountId::from("acct"), &token).is_exact());
410    }
411
412    #[test]
413    fn missing_and_stale_are_kept_for_issued_tokens() {
414        let mut m = map();
415        let token = m.issue(CaseRef::new("trip", "i1", CaseRevision(1)), "l");
416        assert!(m.mark_stale(&token, CaseRevision(2)));
417        assert_eq!(
418            m.resolve(&AccountId::from("acct"), &token),
419            TargetResolution::Stale {
420                case_ref: CaseRef::new("trip", "i1", CaseRevision(1)),
421                current_revision: CaseRevision(2)
422            }
423        );
424        assert!(m.mark_missing(&token));
425        assert_eq!(
426            m.resolve(&AccountId::from("acct"), &token),
427            TargetResolution::Missing
428        );
429        assert!(!m.mark_missing(&TargetToken::from("nope")));
430        assert!(m.candidates().is_empty());
431    }
432
433    #[test]
434    fn candidates_filter_by_workflow() {
435        let mut m = map();
436        m.issue(CaseRef::new("trip", "i1", CaseRevision(1)), "a");
437        m.issue(CaseRef::new("traveler", "c1", CaseRevision(1)), "b");
438        assert_eq!(m.candidates().len(), 2);
439        assert_eq!(m.candidates_for(&WorkflowKey::from("trip")).len(), 1);
440        assert!(m.token_for(&CaseKey::new("traveler", "c1")).is_some());
441    }
442}