Skip to main content

haematite_cli/serve/
drafts.rs

1//! Approved Lys drafts applied to the served store exactly once
2//! (ACCESS-001 R3, PERMISSIONS-ACROSS-THE-STACK D4).
3//!
4//! A held change was drafted with the exact frame its client sent. Once Lys
5//! approves it, the connector pulls it and lys-pass's `execute_once` hands it
6//! here. Its words are decoded as the transport decodes a frame, and must
7//! name exactly the resource and action the draft was approved for, so an
8//! approval never applies an act on anything else. The change is then
9//! applied through the service's own mutation path. That path is
10//! receipt-first: a request id already applied answers its stored receipt
11//! and writes nothing. So the mutation and its receipt are one commit, and
12//! a second run after a lost acknowledgment applies nothing again.
13
14use haematite::tree::Hash;
15use lys_pass::drafts::{ApprovedDraft, Execution, Executor};
16
17use crate::serve::authorize::subjects;
18use crate::serve::contracts::Caller;
19use crate::serve::pass::target;
20use crate::serve::protocol::{Operation, Request};
21use crate::serve::service::Service;
22use crate::serve::storage::Failure;
23
24/// The served store's draft ledger, for the database `database`.
25pub struct Drafts<'a> {
26    service: &'a Service,
27    database: String,
28    /// Whom each applied change is recorded as made by: the service's own
29    /// owner until [`Drafts::by`] names the runner.
30    author: Option<Caller>,
31}
32
33impl<'a> Drafts<'a> {
34    /// Apply approved drafts to `service`, the served database `database`.
35    #[must_use]
36    pub fn new(service: &'a Service, database: &str) -> Self {
37        Self {
38            service,
39            database: database.to_owned(),
40            author: None,
41        }
42    }
43
44    /// Record each applied change as made by `author`, the holder of the
45    /// pass the drafts are run with (ACCESS-003 R4).
46    #[must_use]
47    pub fn by(mut self, author: Caller) -> Self {
48        self.author = Some(author);
49        self
50    }
51}
52
53impl Executor for Drafts<'_> {
54    type Error = Failure;
55
56    /// The outcome is keyed by the branch and request id inside the draft's
57    /// words, which an id alone does not carry, so it is read where the
58    /// change is applied: receipt-first, before anything is written.
59    fn receipt(&self, _draft: &str) -> Result<Option<Execution>, Failure> {
60        Ok(None)
61    }
62
63    /// A draft whose words are not a change on exactly the approved
64    /// resource is refused on execution by its own name, so Lys records the
65    /// refusal and the page moves on. A failure of the store itself stays an
66    /// error: nothing is acknowledged, and the next pull asks again.
67    fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Failure> {
68        let operation = match self.approved_change(draft) {
69            Ok(operation) => operation,
70            Err(refusal) => {
71                return Ok(Execution::RefusedOnExecution {
72                    request_digest: draft.request_digest.clone(),
73                    refusal: refusal.code,
74                    reason: refusal.message,
75                });
76            }
77        };
78        let receipt = self
79            .service
80            .dispatch_by(operation, self.author.as_ref())
81            .map_err(|failure| failure.failure().clone())?;
82        let bytes = serde_json::to_vec(&receipt).map_err(Failure::storage)?;
83        Ok(Execution::Executed {
84            request_digest: draft.request_digest.clone(),
85            receipt_digest: Hash::of(&bytes).to_string(),
86        })
87    }
88}
89
90impl Drafts<'_> {
91    /// The change in `draft`'s words, when they name exactly the resource and
92    /// action the draft was approved for; otherwise the refusal that says why.
93    fn approved_change(&self, draft: &ApprovedDraft) -> Result<Operation, Failure> {
94        let operation = serde_json::from_str::<Request>(&draft.words)
95            .map_err(|error| Failure::new("draft_words_invalid", error.to_string()))?
96            .operation;
97        if !matches!(operation, Operation::Mutate(_)) {
98            return Err(Failure::new(
99                "draft_not_a_change",
100                "only a change is applied from an approved draft",
101            ));
102        }
103        let differs = || {
104            Failure::new(
105                "draft_target_differs",
106                "the draft's words act on another resource than it was approved for",
107            )
108        };
109        let asked = subjects(&operation)?;
110        let [subject] = asked.as_slice() else {
111            return Err(differs());
112        };
113        let named = target(subject.route, &self.database, subject.name.as_deref())
114            .map_err(|error| Failure::new(error.name(), "the draft's resource is invalid"))?;
115        if named != draft.target {
116            return Err(differs());
117        }
118        Ok(operation)
119    }
120}
121
122#[cfg(test)]
123#[path = "drafts_tests.rs"]
124mod tests;