Skip to main content

turnframe_runtime/
resolve.rs

1//! Deterministic target resolution (spec §12).
2//!
3//! A model never sees a record identifier: it chooses among opaque [`TargetToken`]s
4//! this module issued for the cases the actor may address, and understanding hands
5//! back the token, a new case, a record an earlier act of the turn creates, the card on
6//! screen, or several candidates. Several is a selection card, never a guess: nothing
7//! here reads a timestamp, a list position or a confidence (I8).
8//!
9//! | Understood target | How it resolves |
10//! | --- | --- |
11//! | `Record` | through the [`TargetTokenMap`]: unknown is `Unauthorized`, vanished `Missing`, moved `Stale` |
12//! | `New` | only when the operation's [`TargetPolicy`] permits it, minted by a [`CaseIdFactory`] |
13//! | `SameTurn` | the case the earlier act mints, compiled against no state |
14//! | `Card` | the case of the card on screen |
15//! | `Ambiguous` | a selection among the candidates |
16//! | an origin (§12.4) | [`TargetResolver::resolve_origin`]: exactly the record the surface named |
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::sync::Arc;
20
21use indexmap::IndexMap;
22use turnframe_core::case::CaseRef;
23use turnframe_core::hash::derive_uuid;
24use turnframe_core::ids::{
25    AccountId, CaseId, CaseRevision, OriginToken, TargetToken, TurnId, WorkflowKey,
26};
27use turnframe_core::operation::OperationSpec;
28use turnframe_core::plan::TargetPolicy;
29use turnframe_core::reduce::ActiveInteractionSummary;
30use turnframe_core::target::{TargetCandidate, TargetResolution, TargetTokenMap};
31use turnframe_core::understanding::{ActAction, ActId, ActTarget, UnderstoodAct};
32
33/// One case the actor may address this turn, with its server-authored label.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AuthorizedCase {
36    /// The case and the revision it was loaded at.
37    pub case_ref: CaseRef,
38    /// Human label shown to the model and on cards.
39    pub label: String,
40    /// Whether the case is in view only because the actor may reach it; see
41    /// [`CaseCandidate::subject_only_when_named`](crate::orchestrator::CaseCandidate::subject_only_when_named).
42    pub subject_only_when_named: bool,
43}
44
45impl AuthorizedCase {
46    /// A candidate with a label.
47    #[must_use]
48    pub fn new(case_ref: CaseRef, label: impl Into<String>) -> Self {
49        Self {
50            case_ref,
51            label: label.into(),
52            subject_only_when_named: false,
53        }
54    }
55
56    /// Declares that the case belongs to another thread of work.
57    #[must_use]
58    pub const fn reachable_only(mut self) -> Self {
59        self.subject_only_when_named = true;
60        self
61    }
62}
63
64/// Mints the identifier of a case that does not exist yet.
65///
66/// Injected so applications keep their identifier format. It must be a pure function
67/// of its arguments: the identifier is in the plan hash (I20).
68pub trait CaseIdFactory: Send + Sync {
69    /// The identifier for the new case `act` of `turn_id` asks for.
70    fn new_case_id(&self, workflow: &WorkflowKey, turn_id: &TurnId, act: ActId) -> CaseId;
71}
72
73/// Domain-separation prefix of [`DerivedCaseIdFactory`].
74const NEW_CASE_DOMAIN: &str = "turnframe.new_case.v2";
75
76/// Derives the identifier from the turn, the workflow and the act.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub struct DerivedCaseIdFactory;
79
80impl CaseIdFactory for DerivedCaseIdFactory {
81    fn new_case_id(&self, workflow: &WorkflowKey, turn_id: &TurnId, act: ActId) -> CaseId {
82        let uuid = derive_uuid(
83            NEW_CASE_DOMAIN,
84            &[&turn_id.to_string(), workflow.as_str(), &act.to_string()],
85        );
86        CaseId::from(format!("tf_{}", uuid.simple()))
87    }
88}
89
90/// What resolving one act's target produced.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum TargetOutcome {
94    /// The target resolved to a core resolution.
95    Resolved {
96        /// The resolution.
97        resolution: TargetResolution,
98        /// `true` when this act opens the case.
99        new_case: bool,
100    },
101    /// The record an earlier act of the turn opens.
102    SameTurn {
103        /// That case, at revision zero.
104        case_ref: CaseRef,
105    },
106    /// The act addressed the card on screen and there is none.
107    NoActiveInteraction,
108    /// The target is not one the operation accepts (§21.2).
109    PolicyMismatch {
110        /// The policy that refused it.
111        policy: TargetPolicy,
112    },
113    /// A start that resumes an open case, with more than one in view.
114    SeveralOpenCases,
115    /// The act names no record, and its operation needs one.
116    NoTarget,
117}
118
119impl TargetOutcome {
120    /// A plain resolution of an existing case.
121    #[must_use]
122    pub const fn resolved(resolution: TargetResolution) -> Self {
123        Self::Resolved {
124            resolution,
125            new_case: false,
126        }
127    }
128
129    /// The exactly resolved case, when there is one.
130    #[must_use]
131    pub fn exact(&self) -> Option<&CaseRef> {
132        match self {
133            Self::Resolved { resolution, .. } => resolution.exact(),
134            Self::SameTurn { case_ref } => Some(case_ref),
135            _ => None,
136        }
137    }
138
139    /// The core resolution, when the target resolved at all.
140    #[must_use]
141    pub fn resolution(&self) -> Option<TargetResolution> {
142        match self {
143            Self::Resolved { resolution, .. } => Some(resolution.clone()),
144            Self::SameTurn { case_ref } => Some(TargetResolution::Exact {
145                case_ref: case_ref.clone(),
146            }),
147            _ => None,
148        }
149    }
150
151    /// Returns `true` when this act opens the case.
152    #[must_use]
153    pub const fn is_new_case(&self) -> bool {
154        matches!(self, Self::Resolved { new_case: true, .. })
155    }
156
157    /// Returns `true` when the case does not exist yet, opened by this act or an
158    /// earlier one, so it compiles against no state.
159    #[must_use]
160    pub const fn is_unborn(&self) -> bool {
161        matches!(
162            self,
163            Self::Resolved { new_case: true, .. } | Self::SameTurn { .. }
164        )
165    }
166}
167
168/// Builds a [`TargetResolver`] for one turn.
169#[derive(Clone)]
170pub struct TargetResolverBuilder {
171    account_id: AccountId,
172    turn_id: TurnId,
173    candidates: Vec<AuthorizedCase>,
174    origins: IndexMap<OriginToken, CaseRef>,
175    active_interaction: Option<ActiveInteractionSummary>,
176    resuming: BTreeSet<WorkflowKey>,
177    case_ids: Arc<dyn CaseIdFactory>,
178}
179
180impl std::fmt::Debug for TargetResolverBuilder {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("TargetResolverBuilder")
183            .field("account_id", &self.account_id)
184            .field("turn_id", &self.turn_id)
185            .field("candidates", &self.candidates.len())
186            .finish_non_exhaustive()
187    }
188}
189
190impl TargetResolverBuilder {
191    /// Adds an authorized case; a token is issued for it when the resolver is built.
192    #[must_use]
193    pub fn candidate(mut self, candidate: AuthorizedCase) -> Self {
194        self.candidates.push(candidate);
195        self
196    }
197
198    /// Adds several authorized cases.
199    #[must_use]
200    pub fn candidates(mut self, candidates: impl IntoIterator<Item = AuthorizedCase>) -> Self {
201        self.candidates.extend(candidates);
202        self
203    }
204
205    /// Binds a server-validated origin token to the record the surface named (§12.4).
206    #[must_use]
207    pub fn origin(mut self, origin: OriginToken, candidate: AuthorizedCase) -> Self {
208        self.origins.insert(origin, candidate.case_ref.clone());
209        self.candidates.push(candidate);
210        self
211    }
212
213    /// Declares the card on screen, which a `Card` target resolves through.
214    #[must_use]
215    pub fn active_interaction(mut self, summary: ActiveInteractionSummary) -> Self {
216        self.active_interaction = Some(summary);
217        self
218    }
219
220    /// Declares that starting `workflow` reaches the case it already has.
221    #[must_use]
222    pub fn resuming(mut self, workflow: WorkflowKey) -> Self {
223        self.resuming.insert(workflow);
224        self
225    }
226
227    /// Replaces the identifier factory used for new cases.
228    #[must_use]
229    pub fn case_id_factory(mut self, factory: Arc<dyn CaseIdFactory>) -> Self {
230        self.case_ids = factory;
231        self
232    }
233
234    /// Issues the tokens and freezes the candidate list.
235    #[must_use]
236    pub fn build(self) -> TargetResolver {
237        let mut resolver = TargetResolver {
238            tokens: TargetTokenMap::new(self.account_id.clone(), self.turn_id),
239            account_id: self.account_id,
240            turn_id: self.turn_id,
241            candidates: IndexMap::new(),
242            origins: self.origins,
243            active_interaction: self.active_interaction,
244            resuming: self.resuming,
245            case_ids: self.case_ids,
246        };
247        for candidate in self.candidates {
248            resolver.admit(candidate);
249        }
250        resolver
251    }
252}
253
254/// Resolves the targets of one turn against the cases the actor may address.
255#[derive(Clone)]
256pub struct TargetResolver {
257    account_id: AccountId,
258    turn_id: TurnId,
259    tokens: TargetTokenMap,
260    candidates: IndexMap<TargetToken, AuthorizedCase>,
261    origins: IndexMap<OriginToken, CaseRef>,
262    active_interaction: Option<ActiveInteractionSummary>,
263    resuming: BTreeSet<WorkflowKey>,
264    case_ids: Arc<dyn CaseIdFactory>,
265}
266
267impl std::fmt::Debug for TargetResolver {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        f.debug_struct("TargetResolver")
270            .field("account_id", &self.account_id)
271            .field("turn_id", &self.turn_id)
272            .field("tokens", &self.tokens.len())
273            .finish_non_exhaustive()
274    }
275}
276
277impl TargetResolver {
278    /// Starts a builder for `account_id` and `turn_id`.
279    #[must_use]
280    pub fn builder(account_id: AccountId, turn_id: TurnId) -> TargetResolverBuilder {
281        TargetResolverBuilder {
282            account_id,
283            turn_id,
284            candidates: Vec::new(),
285            origins: IndexMap::new(),
286            active_interaction: None,
287            resuming: BTreeSet::new(),
288            case_ids: Arc::new(DerivedCaseIdFactory),
289        }
290    }
291
292    /// Issues a token for a case found after the turn started: a record the user named
293    /// that the application's directory looked up.
294    pub fn admit(&mut self, candidate: AuthorizedCase) -> TargetToken {
295        let token = self
296            .tokens
297            .issue(candidate.case_ref.clone(), candidate.label.clone());
298        self.candidates.insert(token.clone(), candidate);
299        token
300    }
301
302    /// The account every lookup is scoped to.
303    #[must_use]
304    pub const fn account_id(&self) -> &AccountId {
305        &self.account_id
306    }
307
308    /// The turn the tokens were issued for.
309    #[must_use]
310    pub const fn turn_id(&self) -> &TurnId {
311        &self.turn_id
312    }
313
314    /// The token map.
315    #[must_use]
316    pub const fn token_map(&self) -> &TargetTokenMap {
317        &self.tokens
318    }
319
320    /// Every case in view, by token and label.
321    #[must_use]
322    pub fn catalog(&self) -> Vec<TargetCandidate> {
323        self.tokens.candidates()
324    }
325
326    /// The cases in view of one workflow.
327    #[must_use]
328    pub fn catalog_for(&self, workflow: &WorkflowKey) -> Vec<TargetCandidate> {
329        self.tokens.candidates_for(workflow)
330    }
331
332    /// The authorized case behind a token.
333    #[must_use]
334    pub fn candidate(&self, token: &TargetToken) -> Option<&AuthorizedCase> {
335        self.candidates.get(token)
336    }
337
338    /// Every authorized case in view, in the order admitted.
339    pub fn in_view(&self) -> impl Iterator<Item = &AuthorizedCase> {
340        self.candidates.values()
341    }
342
343    /// The card on screen, when there is one.
344    #[must_use]
345    pub const fn active_interaction(&self) -> Option<&ActiveInteractionSummary> {
346        self.active_interaction.as_ref()
347    }
348
349    /// Resolves a server-validated origin reference (§12.4): bound resolves exactly,
350    /// unbound is `Unauthorized`, so a guessed token reveals nothing.
351    #[must_use]
352    pub fn resolve_origin(&self, origin: &OriginToken) -> TargetResolution {
353        match self.origins.get(origin) {
354            Some(case_ref) => TargetResolution::Exact {
355                case_ref: case_ref.clone(),
356            },
357            None => TargetResolution::Unauthorized,
358        }
359    }
360
361    /// The target token that stands for a bound origin, if any.
362    #[must_use]
363    pub fn origin_token(&self, origin: &OriginToken) -> Option<&TargetToken> {
364        let case_ref = self.origins.get(origin)?;
365        self.tokens.token_for(&case_ref.key())
366    }
367
368    /// Resolves one act's target. `spec` is its operation, absent for a start;
369    /// `same_turn` maps the acts of this turn that open a case to the case they open.
370    #[must_use]
371    pub fn resolve(
372        &self,
373        act: &UnderstoodAct,
374        spec: Option<&OperationSpec>,
375        same_turn: &BTreeMap<ActId, CaseRef>,
376    ) -> TargetOutcome {
377        if let ActAction::Start { workflow } = &act.action {
378            return self.start(workflow, act.id);
379        }
380        let policy = spec.map_or(TargetPolicy::RequiresExistingCase, |spec| {
381            spec.target_policy
382        });
383        if !policy.permits(&act.target) {
384            return TargetOutcome::PolicyMismatch { policy };
385        }
386        match &act.target {
387            ActTarget::Record { token } => {
388                TargetOutcome::resolved(self.tokens.resolve(&self.account_id, token))
389            }
390            ActTarget::New { workflow } => self.mint_case(workflow, act.id),
391            ActTarget::SameTurn { act: earlier } => match same_turn.get(earlier) {
392                Some(case_ref) => TargetOutcome::SameTurn {
393                    case_ref: case_ref.clone(),
394                },
395                None => TargetOutcome::resolved(TargetResolution::Missing),
396            },
397            ActTarget::Card => match &self.active_interaction {
398                Some(summary) => TargetOutcome::resolved(TargetResolution::Exact {
399                    case_ref: summary.case_ref.clone(),
400                }),
401                None => TargetOutcome::NoActiveInteraction,
402            },
403            ActTarget::Ambiguous { candidates } => {
404                let candidates: Vec<TargetCandidate> = candidates
405                    .iter()
406                    .filter(|token| self.tokens.resolve(&self.account_id, token).is_exact())
407                    .filter_map(|token| self.candidate_view(token))
408                    .collect();
409                TargetOutcome::resolved(match <[TargetCandidate; 1]>::try_from(candidates) {
410                    Ok([only]) => TargetResolution::Exact {
411                        case_ref: only.case_ref,
412                    },
413                    Err(several) if several.is_empty() => TargetResolution::Missing,
414                    Err(several) => TargetResolution::Ambiguous {
415                        candidates: several,
416                    },
417                })
418            }
419            ActTarget::NotListed { .. } => TargetOutcome::resolved(TargetResolution::Missing),
420            _ => TargetOutcome::NoTarget,
421        }
422    }
423
424    /// Where a start lands: a new case, or the one open case of a workflow that
425    /// resumes; several open cases are refused, since a start has no target to ask about.
426    fn start(&self, workflow: &WorkflowKey, act: ActId) -> TargetOutcome {
427        if !self.resuming.contains(workflow) {
428            return self.mint_case(workflow, act);
429        }
430        let open: Vec<TargetResolution> = self
431            .candidates
432            .iter()
433            .filter(|(_, candidate)| &candidate.case_ref.workflow == workflow)
434            .map(|(token, _)| self.tokens.resolve(&self.account_id, token))
435            .filter(TargetResolution::is_exact)
436            .collect();
437        match <[TargetResolution; 1]>::try_from(open) {
438            Ok([only]) => TargetOutcome::resolved(only),
439            Err(several) if several.is_empty() => self.mint_case(workflow, act),
440            Err(_) => TargetOutcome::SeveralOpenCases,
441        }
442    }
443
444    fn mint_case(&self, workflow: &WorkflowKey, act: ActId) -> TargetOutcome {
445        let case_id = self.case_ids.new_case_id(workflow, &self.turn_id, act);
446        TargetOutcome::Resolved {
447            resolution: TargetResolution::Exact {
448                case_ref: CaseRef::new(workflow.clone(), case_id, CaseRevision::ZERO),
449            },
450            new_case: true,
451        }
452    }
453
454    fn candidate_view(&self, token: &TargetToken) -> Option<TargetCandidate> {
455        let entry = self.tokens.get(token)?;
456        Some(TargetCandidate {
457            token: token.clone(),
458            case_ref: entry.case_ref.clone(),
459            label: entry.label.clone(),
460        })
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use turnframe_core::hash::Digest;
468    use turnframe_core::ids::{InteractionId, OptionId};
469    use turnframe_core::interaction::{InteractionKind, TextResolutionPolicy};
470    use turnframe_core::understanding::{ActStatus, UnitId, WordRange};
471
472    fn case(id: &str, revision: u64) -> CaseRef {
473        CaseRef::new("trip", id, CaseRevision(revision))
474    }
475
476    fn resolver() -> TargetResolver {
477        TargetResolver::builder(AccountId::from("acct"), TurnId::nil())
478            .candidate(AuthorizedCase::new(case("trip-1", 3), "Trip 1"))
479            .candidate(AuthorizedCase::new(case("trip-2", 5), "Trip 2"))
480            .build()
481    }
482
483    fn act(target: ActTarget) -> UnderstoodAct {
484        UnderstoodAct {
485            id: ActId::new(UnitId(1), 1),
486            action: ActAction::Apply {
487                operation: "trip.set_name".into(),
488            },
489            target,
490            arguments: BTreeMap::new(),
491            words: WordRange {
492                first: 0,
493                last: 0,
494                start: 0,
495                end: 1,
496            },
497            depends_on: Vec::new(),
498            status: ActStatus::Ready,
499        }
500    }
501
502    fn spec(policy: TargetPolicy) -> OperationSpec {
503        OperationSpec::new("trip.set_name")
504            .summary("s")
505            .target(policy)
506    }
507
508    fn token(resolver: &TargetResolver, id: &str, revision: u64) -> TargetToken {
509        resolver
510            .token_map()
511            .token_for(&case(id, revision).key())
512            .unwrap()
513            .clone()
514    }
515
516    #[test]
517    fn a_token_resolves_through_the_map_and_a_foreign_one_does_not() {
518        let resolver = resolver();
519        let known = act(ActTarget::Record {
520            token: token(&resolver, "trip-1", 3),
521        });
522        let spec = spec(TargetPolicy::RequiresExistingCase);
523        let none = BTreeMap::new();
524        assert_eq!(
525            resolver.resolve(&known, Some(&spec), &none).exact(),
526            Some(&case("trip-1", 3))
527        );
528        let forged = act(ActTarget::Record {
529            token: "t_forged".into(),
530        });
531        assert_eq!(
532            resolver.resolve(&forged, Some(&spec), &none).resolution(),
533            Some(TargetResolution::Unauthorized)
534        );
535    }
536
537    #[test]
538    fn a_new_case_needs_a_policy_that_allows_it_and_is_derived_from_the_act() {
539        let resolver = resolver();
540        let new = act(ActTarget::New {
541            workflow: "trip".into(),
542        });
543        let none = BTreeMap::new();
544        assert_eq!(
545            resolver.resolve(&new, Some(&spec(TargetPolicy::RequiresExistingCase)), &none),
546            TargetOutcome::PolicyMismatch {
547                policy: TargetPolicy::RequiresExistingCase
548            }
549        );
550        let first = resolver.resolve(&new, Some(&spec(TargetPolicy::NewCaseOnly)), &none);
551        assert!(first.is_new_case());
552        assert_eq!(
553            first,
554            resolver.resolve(&new, Some(&spec(TargetPolicy::NewCaseOnly)), &none),
555            "the same act mints the same identifier (I20)"
556        );
557    }
558
559    #[test]
560    fn a_same_turn_target_is_the_case_the_earlier_act_opens() {
561        let resolver = resolver();
562        let opener = ActId::new(UnitId(1), 1);
563        let dependent = act(ActTarget::SameTurn { act: opener });
564        let minted = BTreeMap::from([(opener, CaseRef::new("trip", "tf_new", CaseRevision::ZERO))]);
565        let outcome = resolver.resolve(
566            &dependent,
567            Some(&spec(TargetPolicy::RequiresExistingCase)),
568            &minted,
569        );
570        assert!(outcome.is_unborn() && !outcome.is_new_case());
571        assert_eq!(outcome.exact().map(|c| c.case_id.as_str()), Some("tf_new"));
572        let orphan = resolver.resolve(
573            &dependent,
574            Some(&spec(TargetPolicy::RequiresExistingCase)),
575            &BTreeMap::new(),
576        );
577        assert_eq!(orphan.resolution(), Some(TargetResolution::Missing));
578    }
579
580    #[test]
581    fn several_candidates_are_a_question_and_one_is_an_answer() {
582        let resolver = resolver();
583        let spec = spec(TargetPolicy::RequiresExistingCase);
584        let both = act(ActTarget::Ambiguous {
585            candidates: vec![token(&resolver, "trip-1", 3), token(&resolver, "trip-2", 5)],
586        });
587        assert!(matches!(
588            resolver.resolve(&both, Some(&spec), &BTreeMap::new()).resolution(),
589            Some(TargetResolution::Ambiguous { candidates }) if candidates.len() == 2
590        ));
591        let one = act(ActTarget::Ambiguous {
592            candidates: vec![token(&resolver, "trip-2", 5)],
593        });
594        assert_eq!(
595            resolver
596                .resolve(&one, Some(&spec), &BTreeMap::new())
597                .exact(),
598            Some(&case("trip-2", 5))
599        );
600    }
601
602    #[test]
603    fn the_card_target_resolves_to_the_card_on_screen() {
604        let summary = ActiveInteractionSummary {
605            interaction_id: InteractionId::nil(),
606            case_ref: case("trip-2", 5),
607            kind: InteractionKind::SingleSelect,
608            blocking: true,
609            option_ids: vec![OptionId::from("a")],
610            text_resolution: TextResolutionPolicy::Never,
611            confirms_risk: turnframe_core::command::RiskClass::ReversibleLowRisk,
612            payload_hash: Digest::of_bytes(b"p"),
613        };
614        let spec = spec(TargetPolicy::ActiveInteractionOnly);
615        let card = act(ActTarget::Card);
616        assert_eq!(
617            resolver().resolve(&card, Some(&spec), &BTreeMap::new()),
618            TargetOutcome::NoActiveInteraction
619        );
620        let with_card = TargetResolver::builder(AccountId::from("acct"), TurnId::nil())
621            .active_interaction(summary)
622            .build();
623        assert_eq!(
624            with_card
625                .resolve(&card, Some(&spec), &BTreeMap::new())
626                .exact(),
627            Some(&case("trip-2", 5))
628        );
629    }
630}