Skip to main content

lys_pass/
drafts.rs

1//! Products pull approved drafts and retain their own atomic execution receipts.
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6#[cfg(feature = "http")]
7use crate::Client;
8use crate::{Error, Mode, Target};
9
10/// A held act, whose words are immutable bytes rather than reserialized JSON.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct DraftRequest {
14    /// The caller's stable operation id, reused for retries of the same prepared act.
15    pub operation: String,
16    /// The held-mode granting receipt.
17    pub grant: String,
18    /// The requested resource and action.
19    pub target: Target,
20    /// SHA-256 of the exact UTF-8 words, in lowercase hex.
21    pub request_digest: String,
22    /// The prepared act's exact words.
23    pub words: String,
24}
25
26/// Lys's acknowledgment of a recorded draft, never an execution.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct DraftCreated {
30    /// The retained draft id.
31    pub draft: String,
32}
33
34/// An approved draft returned to its product's connector.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct ApprovedDraft {
38    /// The immutable draft id.
39    pub id: String,
40    /// The product audience.
41    pub app: String,
42    /// The granting receipt.
43    pub grant: String,
44    /// The exact resource and action.
45    pub target: Target,
46    /// The digest of the exact prepared words.
47    pub request_digest: String,
48    /// Every approver of this immutable draft and request digest, in recorded order.
49    pub approved_by: Vec<String>,
50    /// The granting receipt's required approval mode.
51    pub mode: Mode,
52    /// The exact prepared words.
53    pub words: String,
54}
55
56/// One page of approved drafts; callers explicitly follow the cursor.
57#[derive(Debug, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ApprovedPage {
60    /// Approved drafts of this application's kinds.
61    pub drafts: Vec<ApprovedDraft>,
62    /// The following page's opaque cursor, or the end.
63    pub next: Option<String>,
64    /// The matching population.
65    pub total: usize,
66}
67
68/// A durable product outcome for one immutable request digest.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
71pub enum Execution {
72    /// The mutation and receipt were committed together.
73    Executed {
74        /// The exact request that was applied.
75        request_digest: String,
76        /// The product's committed receipt digest.
77        receipt_digest: String,
78    },
79    /// The product committed a refusal without applying the mutation.
80    RefusedOnExecution {
81        /// The exact request that was refused.
82        request_digest: String,
83        /// The product's own name for the refusal.
84        refusal: String,
85        /// The product's words for why.
86        reason: String,
87    },
88}
89
90impl Execution {
91    fn request_digest(&self) -> &str {
92        match self {
93            Self::Executed { request_digest, .. }
94            | Self::RefusedOnExecution { request_digest, .. } => request_digest,
95        }
96    }
97}
98
99/// An atomic, durable product ledger; the library owns no store or authority.
100pub trait Executor {
101    /// A product storage or execution failure.
102    type Error: std::error::Error + Send + Sync + 'static;
103    /// Read the committed outcome before attempting execution.
104    ///
105    /// # Errors
106    ///
107    /// Returns the product's storage error when its committed ledger cannot
108    /// be read. An id with no committed outcome returns `Ok(None)`.
109    fn receipt(&self, draft: &str) -> Result<Option<Execution>, Self::Error>;
110    /// Atomically apply and record once per draft id; concurrent calls return the same receipt.
111    /// A stored id with another digest must be refused without applying anything.
112    ///
113    /// # Errors
114    ///
115    /// Returns the product's execution or storage error when the atomic
116    /// act and its durable receipt cannot be completed or read back.
117    fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Self::Error>;
118}
119
120/// A refused execution or a preserved product error.
121#[derive(Debug, thiserror::Error)]
122pub enum ExecuteError<E: std::error::Error + 'static> {
123    /// The stored receipt names different immutable words.
124    #[error("draft_receipt_digest_mismatch")]
125    DigestMismatch,
126    /// The product failed to read or commit its ledger.
127    #[error("draft_execution_failed")]
128    Product(#[source] E),
129    /// The draft's words or boundary values do not match its digest.
130    #[error("draft_contract_refused")]
131    Contract(#[source] Error),
132}
133
134/// A stopped pull or execution, preserving a committed outcome on acknowledgment failure.
135#[derive(Debug, thiserror::Error)]
136pub enum RunError<E: std::error::Error + 'static> {
137    /// Lys did not answer a valid approved page.
138    #[error("draft_pull_failed")]
139    Pull(#[source] Error),
140    /// The product did not complete a valid atomic execution.
141    #[error("draft_execution_failed: {draft}")]
142    Execution {
143        /// The draft at which the runner stopped.
144        draft: String,
145        /// The preserved product or digest error.
146        #[source]
147        source: ExecuteError<E>,
148    },
149    /// The product outcome exists but Lys did not acknowledge it.
150    #[error("draft_acknowledgment_failed: {draft}")]
151    Record {
152        /// The draft whose product receipt must be reused on retry.
153        draft: String,
154        /// The committed product receipt, never an instruction to execute again.
155        receipt: Box<Execution>,
156        /// The live acknowledgment failure.
157        #[source]
158        source: Error,
159    },
160}
161
162/// One explicit pull's results, with no background polling or automatic retry.
163#[derive(Debug)]
164pub struct RunReport {
165    /// Draft outcomes successfully acknowledged by Lys, including receipt replays.
166    pub acknowledged: usize,
167    /// The next page to pull explicitly, or the end.
168    pub next: Option<String>,
169}
170
171/// Hash the exact prepared words without canonicalizing or changing them.
172#[must_use]
173pub fn request_digest(words: &str) -> String {
174    let mut text = String::with_capacity(64);
175    let digit = |nibble: u8| {
176        char::from(if nibble < 10 {
177            b'0' + nibble
178        } else {
179            b'a' + nibble - 10
180        })
181    };
182    for byte in Sha256::digest(words.as_bytes()) {
183        text.push(digit(byte >> 4));
184        text.push(digit(byte & 15));
185    }
186    text
187}
188
189/// Return a matching durable receipt before ever invoking a product again.
190///
191/// # Errors
192///
193/// Returns [`ExecuteError::Contract`] for an invalid target, missing grant
194/// or draft id, words that do not match their digest, another application's
195/// target or a malformed executed receipt digest. A ledger read or atomic
196/// execution failure is [`ExecuteError::Product`]; a returned receipt for
197/// different words is [`ExecuteError::DigestMismatch`].
198pub fn execute_once<E: Executor>(
199    draft: &ApprovedDraft,
200    executor: &mut E,
201) -> Result<Execution, ExecuteError<E::Error>> {
202    approval(draft).map_err(ExecuteError::Contract)?;
203    validate(
204        &draft.grant,
205        &draft.target,
206        &draft.request_digest,
207        &draft.words,
208    )
209    .map_err(ExecuteError::Contract)?;
210    if draft.id.is_empty() || !crate::rights::audience_owns(&draft.target.kind, &draft.app) {
211        return Err(ExecuteError::Contract(Error::Invalid(
212            "draft audience or id differs",
213        )));
214    }
215    let receipt = match executor.receipt(&draft.id).map_err(ExecuteError::Product)? {
216        Some(receipt) => receipt,
217        None => executor
218            .execute_and_record(draft)
219            .map_err(ExecuteError::Product)?,
220    };
221    if receipt.request_digest() != draft.request_digest {
222        return Err(ExecuteError::DigestMismatch);
223    }
224    if let Execution::Executed { receipt_digest, .. } = &receipt
225        && (receipt_digest.len() != 64
226            || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
227    {
228        return Err(ExecuteError::Contract(Error::Invalid(
229            "product receipt digest is malformed",
230        )));
231    }
232    Ok(receipt)
233}
234
235fn approval(draft: &ApprovedDraft) -> Result<(), Error> {
236    let distinct: std::collections::BTreeSet<_> = draft.approved_by.iter().collect();
237    if !matches!(
238        (draft.mode, draft.approved_by.len()),
239        (Mode::ByDraft, 1) | (Mode::ByTwo, 2)
240    ) || distinct.len() != draft.approved_by.len()
241        || draft.approved_by.iter().any(String::is_empty)
242    {
243        return Err(Error::Invalid("draft approval mode or approvers differ"));
244    }
245    Ok(())
246}
247
248fn validate(grant: &str, target: &Target, digest: &str, words: &str) -> Result<(), Error> {
249    Target::new(&target.kind, &target.id, &target.action)?;
250    if grant.is_empty() || request_digest(words) != digest {
251        return Err(Error::Invalid("draft words or granting receipt differ"));
252    }
253    Ok(())
254}
255
256#[cfg(feature = "http")]
257impl Client {
258    /// Pull one approved page, invoke the durable executor once per draft, and close each outcome.
259    ///
260    /// # Errors
261    ///
262    /// Stops at [`RunError::Pull`] if the approved page cannot be read or
263    /// validated, or [`RunError::Execution`] if a draft cannot produce a
264    /// valid durable receipt. [`RunError::Record`] retains the committed
265    /// receipt when Lys fails to acknowledge it, so a retry can reuse it.
266    pub async fn run_approved<E: Executor + Send>(
267        &self,
268        connector_pass: &str,
269        app: &str,
270        after: Option<&str>,
271        executor: &mut E,
272    ) -> Result<RunReport, RunError<E::Error>> {
273        let page = self
274            .approved_drafts(connector_pass, app, after)
275            .await
276            .map_err(RunError::Pull)?;
277        let mut report = RunReport {
278            acknowledged: 0,
279            next: page.next,
280        };
281        for draft in &page.drafts {
282            let receipt = execute_once(draft, executor).map_err(|source| RunError::Execution {
283                draft: draft.id.clone(),
284                source,
285            })?;
286            self.record_execution(connector_pass, draft, &receipt)
287                .await
288                .map_err(|source| RunError::Record {
289                    draft: draft.id.clone(),
290                    receipt: Box::new(receipt),
291                    source,
292                })?;
293            report.acknowledged += 1;
294        }
295        Ok(report)
296    }
297
298    /// Record a held act using its caller's pass; an ordinary refusal is never a draft.
299    ///
300    /// # Errors
301    ///
302    /// Refuses a judgment that is not held by this grant, an invalid target,
303    /// missing grant, caller pass or operation, or words that do not match
304    /// their digest. Sending, refusal-status and response-decoding errors
305    /// are preserved as transport failures; an answer without a draft id
306    /// is [`Error::CannotAsk`].
307    pub async fn create_draft(
308        &self,
309        pass: &str,
310        draft: &DraftRequest,
311        judgment: &crate::deliberate::CheckAnswer,
312    ) -> Result<DraftCreated, Error> {
313        if judgment.allowed
314            || !matches!(
315                judgment.mode,
316                Some(crate::Mode::ByDraft | crate::Mode::ByTwo)
317            )
318            || judgment.grant.as_deref() != Some(draft.grant.as_str())
319            || judgment.refusal.is_some()
320        {
321            return Err(Error::Invalid("only a held-mode right may create a draft"));
322        }
323        validate(
324            &draft.grant,
325            &draft.target,
326            &draft.request_digest,
327            &draft.words,
328        )?;
329        if pass.is_empty() || draft.operation.is_empty() {
330            return Err(Error::Invalid("draft caller pass or operation is missing"));
331        }
332        let response = self
333            .http
334            .post(self.api_endpoint("product-drafts")?)
335            .bearer_auth(pass)
336            .json(draft)
337            .send()
338            .await
339            .map_err(|error| Error::Transport(Box::new(error)))?;
340        let created: DraftCreated = response
341            .error_for_status()
342            .map_err(|error| Error::Transport(Box::new(error)))?
343            .json()
344            .await
345            .map_err(|error| Error::Transport(Box::new(error)))?;
346        if created.draft.is_empty() {
347            return Err(Error::CannotAsk("draft id is missing"));
348        }
349        Ok(created)
350    }
351
352    /// Pull one page with the product connector, without a timer or background loop.
353    ///
354    /// # Errors
355    ///
356    /// Refuses a missing connector pass or app and propagates transport,
357    /// refusal-status and response-decoding failures. A page with an
358    /// inconsistent total or repeated continuation is refused, as is a
359    /// draft with a missing grant, words whose digest differs, an invalid
360    /// target, another app, a missing or repeated id, or a resource kind
361    /// that app does not own.
362    pub async fn approved_drafts(
363        &self,
364        connector_pass: &str,
365        app: &str,
366        after: Option<&str>,
367    ) -> Result<ApprovedPage, Error> {
368        if connector_pass.is_empty() || app.is_empty() {
369            return Err(Error::Invalid("draft connector or app is missing"));
370        }
371        let mut url = self.api_endpoint("product-drafts")?;
372        url.query_pairs_mut()
373            .append_pair("app", app)
374            .append_pair("state", "approved");
375        if let Some(after) = after {
376            url.query_pairs_mut().append_pair("after", after);
377        }
378        let response = self
379            .http
380            .get(url)
381            .bearer_auth(connector_pass)
382            .send()
383            .await
384            .map_err(|error| Error::Transport(Box::new(error)))?;
385        let page: ApprovedPage = response
386            .error_for_status()
387            .map_err(|error| Error::Transport(Box::new(error)))?
388            .json()
389            .await
390            .map_err(|error| Error::Transport(Box::new(error)))?;
391        if page.total < page.drafts.len()
392            || after.is_some_and(|cursor| page.next.as_deref() == Some(cursor))
393        {
394            return Err(Error::CannotAsk("draft population or continuation differs"));
395        }
396        let mut ids = std::collections::BTreeSet::new();
397        for draft in &page.drafts {
398            approval(draft)?;
399            validate(
400                &draft.grant,
401                &draft.target,
402                &draft.request_digest,
403                &draft.words,
404            )?;
405            if draft.app != app
406                || draft.id.is_empty()
407                || !crate::rights::audience_owns(&draft.target.kind, app)
408                || !ids.insert(&draft.id)
409            {
410                return Err(Error::CannotAsk("approved draft belongs to another app"));
411            }
412        }
413        Ok(page)
414    }
415
416    /// Acknowledge the product's durable outcome; failure leaves its local receipt intact.
417    ///
418    /// # Errors
419    ///
420    /// Refuses a missing connector pass or draft id, a receipt naming
421    /// different words, a malformed executed receipt digest or an execution
422    /// refusal without a name and reason. Endpoint construction, transport
423    /// and refusal-status failures are returned without changing the
424    /// product's committed receipt.
425    pub async fn record_execution(
426        &self,
427        connector_pass: &str,
428        draft: &ApprovedDraft,
429        receipt: &Execution,
430    ) -> Result<(), Error> {
431        if connector_pass.is_empty() || receipt.request_digest() != draft.request_digest {
432            return Err(Error::Invalid("execution receipt differs from draft"));
433        }
434        let route = match receipt {
435            Execution::Executed { .. } => "executed",
436            Execution::RefusedOnExecution { .. } => "refused-on-execution",
437        };
438        if draft.id.is_empty() {
439            return Err(Error::Invalid("draft id is missing"));
440        }
441        let mut url = self.api_endpoint("product-drafts/")?;
442        url.path_segments_mut()
443            .map_err(|()| Error::Invalid("issuer cannot hold draft paths"))?
444            .pop_if_empty()
445            .push(&draft.id)
446            .push(route);
447        let body = match receipt {
448            Execution::Executed { receipt_digest, .. } => {
449                if receipt_digest.len() != 64
450                    || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit())
451                {
452                    return Err(Error::Invalid("execution receipt digest is malformed"));
453                }
454                serde_json::json!({ "receipt_digest": receipt_digest })
455            }
456            Execution::RefusedOnExecution {
457                refusal, reason, ..
458            } => {
459                if refusal.is_empty() || reason.is_empty() {
460                    return Err(Error::Invalid(
461                        "execution refusal needs a name and a reason",
462                    ));
463                }
464                serde_json::json!({ "refusal": refusal, "reason": reason })
465            }
466        };
467        self.http
468            .post(url)
469            .bearer_auth(connector_pass)
470            .json(&body)
471            .send()
472            .await
473            .map_err(|error| Error::Transport(Box::new(error)))?
474            .error_for_status()
475            .map_err(|error| Error::Transport(Box::new(error)))?;
476        Ok(())
477    }
478}