Skip to main content

headless_lms_server/controllers/mock_suotar/
faults.rs

1//! Addressed faults: what can go wrong that the world's own data cannot express.
2//!
3//! Predicates are AND-ed and order-independent, so a miss can name the single predicate that failed.
4//! No HTTP and no Redis: matching is decided from the fault and the request's item addresses alone.
5
6use headless_lms_utils::services::suotar::SuotarEndpoint;
7
8use crate::prelude::*;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub enum Stage {
13    Auth,
14    RequestGate,
15    Parse,
16    Resolve,
17    AfterWrite,
18    Respond,
19}
20
21impl Stage {
22    pub const ALL: [Self; 6] = [
23        Self::Auth,
24        Self::RequestGate,
25        Self::Parse,
26        Self::Resolve,
27        Self::AfterWrite,
28        Self::Respond,
29    ];
30
31    /// True where the write-back pipeline has already committed.
32    pub fn is_post_commit(self) -> bool {
33        matches!(self, Self::AfterWrite | Self::Respond)
34    }
35
36    /// True where the body has not been read yet, so an owner-narrowed fault has to be deferred.
37    pub fn is_pre_load(self) -> bool {
38        matches!(self, Self::Auth | Self::RequestGate | Self::Parse)
39    }
40
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Auth => "auth",
44            Self::RequestGate => "requestGate",
45            Self::Parse => "parse",
46            Self::Resolve => "resolve",
47            Self::AfterWrite => "afterWrite",
48            Self::Respond => "respond",
49        }
50    }
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct OwnerRef {
56    pub user: Option<String>,
57    pub course: Option<String>,
58}
59
60impl OwnerRef {
61    pub fn is_empty(&self) -> bool {
62        self.user.is_none() && self.course.is_none()
63    }
64}
65
66/// An owner turned into the keys the wire carries, resolved once at arm time: a fault whose meaning
67/// changed because the user linked in the meantime would be unassertable.
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct ResolvedOwner {
71    pub user: Option<String>,
72    pub course: Option<String>,
73    pub student_numbers: Vec<String>,
74    pub course_codes: Vec<String>,
75    pub product_ids: Vec<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub enum Predicate {
81    Endpoint(SuotarEndpoint),
82    Stage(Stage),
83    StudentNumber(String),
84    CourseCode(String),
85    Owner(OwnerRef),
86}
87
88impl Predicate {
89    pub fn key(&self) -> &'static str {
90        match self {
91            Self::Endpoint(_) => "endpoint",
92            Self::Stage(_) => "stage",
93            Self::StudentNumber(_) => "studentNumber",
94            Self::CourseCode(_) => "courseCode",
95            Self::Owner(_) => "owner",
96        }
97    }
98
99    /// Names data one spec owns, which is what keeps a fault inside its own traffic.
100    fn is_owner_key(&self) -> bool {
101        matches!(
102            self,
103            Self::StudentNumber(_) | Self::CourseCode(_) | Self::Owner(_)
104        )
105    }
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase", tag = "kind")]
110pub enum Effect {
111    #[serde(rename_all = "camelCase")]
112    ItemLevel {
113        code: String,
114        message: Option<String>,
115        #[serde(default)]
116        disclose_submitted_attainment_id: bool,
117    },
118    RequestLevel {
119        status: u16,
120        code: String,
121        message: Option<String>,
122    },
123    ConnectionReset,
124}
125
126impl Effect {
127    /// Derived from the kind, never declared: a descriptor naming both `level: item` and
128    /// `kind: connectionReset` is nonsense.
129    pub fn is_request_shaped(&self) -> bool {
130        !matches!(self, Self::ItemLevel { .. })
131    }
132
133    pub fn code(&self) -> Option<&str> {
134        match self {
135            Self::ItemLevel { code, .. } | Self::RequestLevel { code, .. } => Some(code),
136            Self::ConnectionReset => None,
137        }
138    }
139
140    pub fn kind(&self) -> &'static str {
141        match self {
142            Self::ItemLevel { .. } => "itemLevel",
143            Self::RequestLevel { .. } => "requestLevel",
144            Self::ConnectionReset => "connectionReset",
145        }
146    }
147}
148
149#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct Lifetime {
152    pub matching_calls: Option<u32>,
153    pub matching_items: Option<u32>,
154}
155
156impl Lifetime {
157    pub fn budget(&self) -> Option<u32> {
158        self.matching_calls.or(self.matching_items)
159    }
160}
161
162#[derive(Debug, Clone, Default, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct FlatWhen {
165    pub endpoint: Option<SuotarEndpoint>,
166    pub stage: Option<Stage>,
167    pub student_number: Option<String>,
168    pub course_code: Option<String>,
169    pub owner: Option<OwnerRef>,
170}
171
172/// A flat literal is sugar and desugars into the predicate list.
173#[derive(Debug, Clone, Deserialize)]
174#[serde(untagged)]
175pub enum WhenSpec {
176    Predicates(Vec<Predicate>),
177    Flat(Box<FlatWhen>),
178}
179
180impl WhenSpec {
181    pub fn into_predicates(self) -> Vec<Predicate> {
182        match self {
183            Self::Predicates(predicates) => predicates,
184            Self::Flat(flat) => {
185                let mut predicates = Vec::new();
186                if let Some(endpoint) = flat.endpoint {
187                    predicates.push(Predicate::Endpoint(endpoint));
188                }
189                if let Some(stage) = flat.stage {
190                    predicates.push(Predicate::Stage(stage));
191                }
192                if let Some(value) = flat.student_number {
193                    predicates.push(Predicate::StudentNumber(value));
194                }
195                if let Some(value) = flat.course_code {
196                    predicates.push(Predicate::CourseCode(value));
197                }
198                if let Some(value) = flat.owner {
199                    predicates.push(Predicate::Owner(value));
200                }
201                predicates
202            }
203        }
204    }
205}
206
207#[derive(Debug, Clone, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct FaultSpec {
210    pub id: String,
211    pub when: WhenSpec,
212    pub then: Effect,
213    #[serde(default)]
214    pub lifetime: Lifetime,
215    /// Required to arm the one combination that would otherwise pin a double submission into a
216    /// green build.
217    #[serde(default)]
218    pub proves_double_submission: bool,
219}
220
221/// An armed fault. Immutable: re-arming an id replaces the value and re-mints its `seq`.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct Fault {
225    pub id: String,
226    /// Arm order, which is precedence: first match wins.
227    pub seq: u64,
228    pub when: Vec<Predicate>,
229    pub then: Effect,
230    pub lifetime: Lifetime,
231    pub proves_double_submission: bool,
232    pub owner: Option<ResolvedOwner>,
233    pub parallel_safe: bool,
234    pub armed_at: DateTime<Utc>,
235}
236
237impl Fault {
238    pub fn endpoint(&self) -> Option<SuotarEndpoint> {
239        self.when.iter().find_map(|predicate| match predicate {
240            Predicate::Endpoint(endpoint) => Some(*endpoint),
241            _ => None,
242        })
243    }
244
245    pub fn stage(&self) -> Option<Stage> {
246        self.when.iter().find_map(|predicate| match predicate {
247            Predicate::Stage(stage) => Some(*stage),
248            _ => None,
249        })
250    }
251
252    pub fn has_owner_key(&self) -> bool {
253        self.when.iter().any(Predicate::is_owner_key)
254    }
255}
256
257/// The address keys one request item carries, after the working-set load filled in what the wire
258/// did not.
259#[derive(Debug, Clone, Default, PartialEq, Eq)]
260pub struct ItemAddress {
261    pub request_item_id: String,
262    pub student_number: Option<String>,
263    pub course_code: Option<String>,
264    pub product_id: Option<String>,
265    pub submitted_attainment_id: Option<String>,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum FaultMatch {
270    Fires,
271    /// The key of the predicate that failed.
272    Missed(&'static str),
273}
274
275pub fn matches_item(
276    fault: &Fault,
277    endpoint: SuotarEndpoint,
278    stage: Stage,
279    item: &ItemAddress,
280) -> FaultMatch {
281    for predicate in &fault.when {
282        let satisfied = match predicate {
283            Predicate::Endpoint(wanted) => *wanted == endpoint,
284            Predicate::Stage(wanted) => *wanted == stage,
285            Predicate::StudentNumber(wanted) => item.student_number.as_deref() == Some(wanted),
286            Predicate::CourseCode(wanted) => item.course_code.as_deref() == Some(wanted),
287            Predicate::Owner(_) => fault
288                .owner
289                .as_ref()
290                .is_some_and(|owner| owner_matches(owner, item)),
291        };
292        if !satisfied {
293            return FaultMatch::Missed(predicate.key());
294        }
295    }
296    FaultMatch::Fires
297}
298
299/// A request-shaped effect fires only when **every** item resolves to the fault's owner: on a mixed
300/// batch it would otherwise kill rows nobody armed anything for.
301pub fn matches_request(
302    fault: &Fault,
303    endpoint: SuotarEndpoint,
304    stage: Stage,
305    items: &[ItemAddress],
306) -> FaultMatch {
307    if !fault.has_owner_key() {
308        return matches_item(fault, endpoint, stage, &ItemAddress::default());
309    }
310    if items.is_empty() {
311        return FaultMatch::Missed("owner");
312    }
313    let mut missed = None;
314    for item in items {
315        match matches_item(fault, endpoint, stage, item) {
316            FaultMatch::Fires => {}
317            FaultMatch::Missed(predicate) => missed = Some(predicate),
318        }
319    }
320    match missed {
321        Some(predicate) => FaultMatch::Missed(predicate),
322        None => FaultMatch::Fires,
323    }
324}
325
326/// Each half constrains only the keys the item carries, so on `list-by-course` — which carries no
327/// student number — the course half alone decides.
328fn owner_matches(owner: &ResolvedOwner, item: &ItemAddress) -> bool {
329    let mut constrained = false;
330    if owner.user.is_some()
331        && let Some(student_number) = &item.student_number
332    {
333        if !owner.student_numbers.contains(student_number) {
334            return false;
335        }
336        constrained = true;
337    }
338    if owner.course.is_some() {
339        if let Some(course_code) = &item.course_code {
340            if !owner.course_codes.contains(course_code) {
341                return false;
342            }
343            constrained = true;
344        }
345        if let Some(product_id) = &item.product_id {
346            if !owner.product_ids.contains(product_id) {
347                return false;
348            }
349            constrained = true;
350        }
351    }
352    constrained
353}
354
355pub struct FaultProblem {
356    pub code: String,
357    pub message: String,
358}
359
360impl FaultProblem {
361    fn new(code: &str, message: String) -> Self {
362        Self {
363            code: code.to_string(),
364            message,
365        }
366    }
367}
368
369/// What an endpoint can resolve, not what its body carries: verify's body holds only a submitted
370/// attainment id, and the working set reads the person behind it.
371fn resolvable_keys(endpoint: SuotarEndpoint) -> &'static [&'static str] {
372    match endpoint {
373        SuotarEndpoint::ResolvePersons => &["studentNumber", "owner"],
374        SuotarEndpoint::ResolveEnrolments
375        | SuotarEndpoint::ImportAttainments
376        | SuotarEndpoint::VerifyAttainments => &["studentNumber", "courseCode", "owner"],
377        SuotarEndpoint::ProductAccessTokens => &["owner"],
378        SuotarEndpoint::ListByCourse => &["courseCode", "owner"],
379    }
380}
381
382/// Read from the state machine rather than restated, so this guard cannot drift from the class it
383/// guards.
384fn is_retryable_transient_code(code: &str) -> bool {
385    headless_lms_models::library::credit_registration::classification::is_retryable_transient_wire_code(
386        code,
387    )
388}
389
390/// Whether the endpoint's contract lists a transient code among its per-item results;
391/// `resolve-enrolments` and `import` carry it only in the request-level form.
392fn carries_item_level_transient(endpoint: SuotarEndpoint) -> bool {
393    matches!(
394        endpoint,
395        SuotarEndpoint::ResolvePersons
396            | SuotarEndpoint::VerifyAttainments
397            | SuotarEndpoint::ProductAccessTokens
398            | SuotarEndpoint::ListByCourse
399    )
400}
401
402/// Rejects a fault that could never fire, and the one combination that would fire and be wrong.
403pub fn validate(
404    predicates: &[Predicate],
405    effect: &Effect,
406    proves_double_submission: bool,
407) -> Result<(SuotarEndpoint, Stage), FaultProblem> {
408    let mut endpoint = None;
409    let mut stage = None;
410    let mut seen = Vec::new();
411    for predicate in predicates {
412        if seen.contains(&predicate.key()) {
413            return Err(FaultProblem::new(
414                "invalidFault",
415                format!("The predicate `{}` is given twice.", predicate.key()),
416            ));
417        }
418        seen.push(predicate.key());
419        match predicate {
420            Predicate::Endpoint(value) => endpoint = Some(*value),
421            Predicate::Stage(value) => stage = Some(*value),
422            _ => {}
423        }
424    }
425    let Some(endpoint) = endpoint else {
426        return Err(FaultProblem::new(
427            "invalidFault",
428            "A fault must name an `endpoint`.".to_string(),
429        ));
430    };
431    let Some(stage) = stage else {
432        return Err(FaultProblem::new(
433            "invalidFault",
434            format!(
435                "A fault must name a `stage`, one of {}. There is no default, because a fault at a post-commit stage means something different from the same fault before the write.",
436                Stage::ALL
437                    .iter()
438                    .map(|s| s.as_str())
439                    .collect::<Vec<_>>()
440                    .join(", ")
441            ),
442        ));
443    };
444
445    let resolvable = resolvable_keys(endpoint);
446    for predicate in predicates {
447        let key = predicate.key();
448        if matches!(key, "endpoint" | "stage") {
449            continue;
450        }
451        if !resolvable.contains(&key) {
452            return Err(FaultProblem::new(
453                "invalidFault",
454                format!(
455                    "`{key}` cannot be resolved on this endpoint. It resolves: {}.",
456                    resolvable.join(", ")
457                ),
458            ));
459        }
460    }
461
462    if let Some(Predicate::Owner(owner)) = predicates
463        .iter()
464        .find(|predicate| matches!(predicate, Predicate::Owner(_)))
465        && owner.is_empty()
466    {
467        return Err(FaultProblem::new(
468            "invalidFault",
469            "`owner` must name a user, a course, or both.".to_string(),
470        ));
471    }
472
473    if matches!(effect, Effect::ItemLevel { .. }) && stage.is_pre_load() {
474        return Err(FaultProblem::new(
475            "invalidFault",
476            format!(
477                "An item-level effect has no item to attach to at `{}`, which is decided before the body is read. Use `resolve`, `afterWrite` or `respond`.",
478                stage.as_str()
479            ),
480        ));
481    }
482
483    // Naming an unexpected code is allowed on purpose; only the transient class is refused, because
484    // it is the one that would teach a client to retry a body Suotar could never have sent.
485    if matches!(effect, Effect::ItemLevel { .. })
486        && effect.code().is_some_and(is_retryable_transient_code)
487        && !carries_item_level_transient(endpoint)
488    {
489        return Err(FaultProblem::new(
490            "invalidFault",
491            format!(
492                "This endpoint carries no item-level `{}`; Suotar can only fail the whole request that way. Use a `requestLevel` effect.",
493                effect.code().unwrap_or_default()
494            ),
495        ));
496    }
497
498    if endpoint == SuotarEndpoint::ImportAttainments
499        && stage.is_post_commit()
500        && effect.code().is_some_and(is_retryable_transient_code)
501        && !proves_double_submission
502    {
503        return Err(FaultProblem::new(
504            "refusedFault",
505            format!(
506                "An import answered with `{}` after the write has committed holds the attainment and tells the client to retry, which is the double submission. Set `provesDoubleSubmission: true` if that is what the spec is proving.",
507                effect.code().unwrap_or_default()
508            ),
509        ));
510    }
511
512    Ok((endpoint, stage))
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn predicates(stage: Stage) -> Vec<Predicate> {
520        vec![
521            Predicate::Endpoint(SuotarEndpoint::ImportAttainments),
522            Predicate::Stage(stage),
523        ]
524    }
525
526    fn transient(item_level: bool) -> Effect {
527        if item_level {
528            Effect::ItemLevel {
529                code: "sisuTemporarilyUnavailable".to_string(),
530                message: None,
531                disclose_submitted_attainment_id: false,
532            }
533        } else {
534            Effect::RequestLevel {
535                status: 503,
536                code: "sisuTemporarilyUnavailable".to_string(),
537                message: None,
538            }
539        }
540    }
541
542    #[test]
543    fn a_retryable_code_after_the_import_write_is_refused_unless_it_is_the_point() {
544        for stage in [Stage::AfterWrite, Stage::Respond] {
545            assert!(
546                validate(&predicates(stage), &transient(false), false).is_err(),
547                "{stage:?} was not refused"
548            );
549            assert!(validate(&predicates(stage), &transient(false), true).is_ok());
550        }
551        // Before the write there is no attainment being held, so nothing needs excusing.
552        assert!(validate(&predicates(Stage::RequestGate), &transient(false), false).is_ok());
553    }
554
555    /// The double-submission flag excuses a double submission, not a response shape Suotar cannot
556    /// produce.
557    #[test]
558    fn an_item_level_transient_is_refused_where_the_contract_carries_none() {
559        for endpoint in [
560            SuotarEndpoint::ImportAttainments,
561            SuotarEndpoint::ResolveEnrolments,
562        ] {
563            for stage in [Stage::Resolve, Stage::AfterWrite, Stage::Respond] {
564                let when = vec![Predicate::Endpoint(endpoint), Predicate::Stage(stage)];
565                let problem = validate(&when, &transient(true), true)
566                    .err()
567                    .unwrap_or_else(|| {
568                        panic!("{endpoint:?} at {stage:?} accepted an impossible item code")
569                    });
570                assert!(problem.message.contains("requestLevel"));
571                assert!(
572                    validate(&when, &transient(false), stage.is_post_commit()).is_ok(),
573                    "the request-level form is the way to drive it"
574                );
575            }
576        }
577        for endpoint in [
578            SuotarEndpoint::ResolvePersons,
579            SuotarEndpoint::VerifyAttainments,
580            SuotarEndpoint::ProductAccessTokens,
581            SuotarEndpoint::ListByCourse,
582        ] {
583            let when = vec![
584                Predicate::Endpoint(endpoint),
585                Predicate::Stage(Stage::Resolve),
586            ];
587            assert!(
588                validate(&when, &transient(true), false).is_ok(),
589                "{endpoint:?} does carry the transient item code"
590            );
591        }
592    }
593
594    #[test]
595    fn a_key_the_endpoint_cannot_resolve_is_refused_and_an_indirect_one_is_not() {
596        let unresolvable = vec![
597            Predicate::Endpoint(SuotarEndpoint::ListByCourse),
598            Predicate::Stage(Stage::Resolve),
599            Predicate::StudentNumber("900000101".to_string()),
600        ];
601        let problem = validate(&unresolvable, &transient(true), false)
602            .expect_err("list-by-course carries no student number");
603        assert!(problem.message.contains("courseCode"));
604
605        let indirect = vec![
606            Predicate::Endpoint(SuotarEndpoint::VerifyAttainments),
607            Predicate::Stage(Stage::Resolve),
608            Predicate::Owner(OwnerRef {
609                user: Some("someone@example.com".to_string()),
610                course: None,
611            }),
612        ];
613        assert!(validate(&indirect, &transient(true), false).is_ok());
614    }
615}