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}