Skip to main content

turnframe_runtime/orchestrator/
directory.rs

1//! Which cases an actor may address: the application's authorization boundary for
2//! records (spec §23 step D, §25.4).
3
4use async_trait::async_trait;
5use turnframe_core::case::CaseKey;
6use turnframe_core::error::StoreError;
7use turnframe_core::ids::{ConversationId, OriginToken, WorkflowKey};
8use turnframe_core::turn::ActorContext;
9
10/// One case the actor may address in a conversation.
11///
12/// It carries no revision: the executor is read at the start of every turn, so a
13/// turn never plans against a state that has moved (I1, I13).
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct CaseCandidate {
16    /// Workflow and case identifier.
17    pub key: CaseKey,
18    /// Server-authored label the model and the cards see instead of the id.
19    pub label: String,
20    /// Every write on this case needs a click, as `AskBeforeApplying` would ask.
21    ///
22    /// For a record the actor may reach and did not necessarily mean. It raises a
23    /// confirmation and never refuses.
24    pub confirm_every_write: bool,
25    /// The case is in view only because the actor may reach it.
26    ///
27    /// It stays addressable, and becomes a subject only when an act names it: it does
28    /// not hold the door against a new case, brief the writer or ask for its fields.
29    /// See [`ReductionContext::subject_only_when_named`](turnframe_core::reduce::ReductionContext::subject_only_when_named).
30    pub subject_only_when_named: bool,
31}
32
33impl CaseCandidate {
34    /// A candidate with a label.
35    #[must_use]
36    pub fn new(key: CaseKey, label: impl Into<String>) -> Self {
37        Self {
38            key,
39            label: label.into(),
40            confirm_every_write: false,
41            subject_only_when_named: false,
42        }
43    }
44
45    /// Declares that every write on this case has to pass through a click.
46    #[must_use]
47    pub const fn confirming_every_write(mut self) -> Self {
48        self.confirm_every_write = true;
49        self
50    }
51
52    /// Declares that this case is a subject of a turn only when the turn names it.
53    #[must_use]
54    pub const fn subject_only_when_named(mut self) -> Self {
55        self.subject_only_when_named = true;
56        self
57    }
58}
59
60/// Names the cases an actor may address (spec §23 step D, §25.4).
61///
62/// Whatever it does not return, the model never learns exists. The account is the
63/// boundary the runtime enforces; any narrower scope (an organization, a workspace)
64/// is enforced here and nowhere else, because the executor's `load` never sees the
65/// actor. Every case a turn addresses passes through one of these methods.
66#[async_trait]
67pub trait CaseDirectory: Send + Sync {
68    /// The cases `actor` may address in `conversation`. A record a turn created is
69    /// addressable on the next turn only when it is listed here.
70    ///
71    /// # Errors
72    ///
73    /// [`StoreError`] when the directory could not be read. The turn fails closed (I19).
74    async fn candidates(
75        &self,
76        actor: &ActorContext,
77        conversation: &ConversationId,
78    ) -> Result<Vec<CaseCandidate>, StoreError>;
79
80    /// Whether `actor` may address `key`, an existing record an open card names that
81    /// [`candidates`](Self::candidates) did not return. `None` refuses, and the card
82    /// is dropped from the turn.
83    ///
84    /// **The default refuses**, so the candidate list is the whole definition of what
85    /// an actor may address. Override it when that list is deliberately narrower.
86    ///
87    /// # Errors
88    ///
89    /// [`StoreError`] when the lookup failed. The turn fails closed (I19).
90    async fn authorize_case(
91        &self,
92        actor: &ActorContext,
93        conversation: &ConversationId,
94        key: &CaseKey,
95    ) -> Result<Option<CaseCandidate>, StoreError> {
96        let _ = (actor, conversation, key);
97        Ok(None)
98    }
99
100    /// The records of `workflow` the actor may address that `named` names, for a
101    /// message naming one [`candidates`](Self::candidates) did not list. `named` is
102    /// the user's words, when they used any. One found record is the act's target;
103    /// several are offered on a selection card. The default finds none.
104    ///
105    /// # Errors
106    ///
107    /// [`StoreError`] when the lookup failed. The turn fails closed (I19).
108    async fn find(
109        &self,
110        actor: &ActorContext,
111        conversation: &ConversationId,
112        workflow: &WorkflowKey,
113        named: Option<&str>,
114    ) -> Result<Vec<CaseCandidate>, StoreError> {
115        let _ = (actor, conversation, workflow, named);
116        Ok(Vec::new())
117    }
118
119    /// The record a server-issued origin token names (spec §12.4). The default
120    /// recognises none.
121    ///
122    /// # Errors
123    ///
124    /// [`StoreError`] when the lookup failed.
125    async fn resolve_origin(
126        &self,
127        actor: &ActorContext,
128        origin: &OriginToken,
129    ) -> Result<Option<CaseCandidate>, StoreError> {
130        let _ = (actor, origin);
131        Ok(None)
132    }
133}
134
135/// A directory with a fixed list, for tests, examples and single-case surfaces.
136///
137/// It keeps the default [`authorize_case`](CaseDirectory::authorize_case), so the list
138/// is the whole truth.
139#[derive(Debug, Clone, Default)]
140pub struct StaticCaseDirectory {
141    candidates: Vec<CaseCandidate>,
142    origins: Vec<(OriginToken, CaseCandidate)>,
143}
144
145impl StaticCaseDirectory {
146    /// An empty directory.
147    #[must_use]
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Adds a candidate.
153    #[must_use]
154    pub fn with_case(mut self, candidate: CaseCandidate) -> Self {
155        self.candidates.push(candidate);
156        self
157    }
158
159    /// Binds an origin token to a record.
160    #[must_use]
161    pub fn with_origin(mut self, origin: OriginToken, candidate: CaseCandidate) -> Self {
162        self.origins.push((origin, candidate));
163        self
164    }
165}
166
167#[async_trait]
168impl CaseDirectory for StaticCaseDirectory {
169    async fn candidates(
170        &self,
171        _actor: &ActorContext,
172        _conversation: &ConversationId,
173    ) -> Result<Vec<CaseCandidate>, StoreError> {
174        Ok(self.candidates.clone())
175    }
176
177    async fn resolve_origin(
178        &self,
179        _actor: &ActorContext,
180        origin: &OriginToken,
181    ) -> Result<Option<CaseCandidate>, StoreError> {
182        Ok(self
183            .origins
184            .iter()
185            .find(|(token, _)| token == origin)
186            .map(|(_, candidate)| candidate.clone()))
187    }
188}