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, 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    /// The exact prepared words.
49    pub words: String,
50}
51
52/// One page of approved drafts; callers explicitly follow the cursor.
53#[derive(Debug, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct ApprovedPage {
56    /// Approved drafts of this application's kinds.
57    pub drafts: Vec<ApprovedDraft>,
58    /// The following page's opaque cursor, or the end.
59    pub next: Option<String>,
60    /// The matching population.
61    pub total: usize,
62}
63
64/// A durable product outcome for one immutable request digest.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
67pub enum Execution {
68    /// The mutation and receipt were committed together.
69    Executed {
70        /// The exact request that was applied.
71        request_digest: String,
72        /// The product's committed receipt digest.
73        receipt_digest: String,
74    },
75    /// The product committed a refusal without applying the mutation.
76    RefusedOnExecution {
77        /// The exact request that was refused.
78        request_digest: String,
79        /// The product's own name for the refusal.
80        refusal: String,
81        /// The product's words for why.
82        reason: String,
83    },
84}
85
86impl Execution {
87    fn request_digest(&self) -> &str {
88        match self {
89            Self::Executed { request_digest, .. }
90            | Self::RefusedOnExecution { request_digest, .. } => request_digest,
91        }
92    }
93}
94
95/// An atomic, durable product ledger; the library owns no store or authority.
96pub trait Executor {
97    /// A product storage or execution failure.
98    type Error: std::error::Error + Send + Sync + 'static;
99    /// Read the committed outcome before attempting execution.
100    fn receipt(&self, draft: &str) -> Result<Option<Execution>, Self::Error>;
101    /// Atomically apply and record once per draft id; concurrent calls return the same receipt.
102    /// A stored id with another digest must be refused without applying anything.
103    fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Self::Error>;
104}
105
106/// A refused execution or a preserved product error.
107#[derive(Debug, thiserror::Error)]
108pub enum ExecuteError<E: std::error::Error + 'static> {
109    /// The stored receipt names different immutable words.
110    #[error("draft_receipt_digest_mismatch")]
111    DigestMismatch,
112    /// The product failed to read or commit its ledger.
113    #[error("draft_execution_failed")]
114    Product(#[source] E),
115    /// The draft's words or boundary values do not match its digest.
116    #[error("draft_contract_refused")]
117    Contract(#[source] Error),
118}
119
120/// A stopped pull or execution, preserving a committed outcome on acknowledgment failure.
121#[derive(Debug, thiserror::Error)]
122pub enum RunError<E: std::error::Error + 'static> {
123    /// Lys did not answer a valid approved page.
124    #[error("draft_pull_failed")]
125    Pull(#[source] Error),
126    /// The product did not complete a valid atomic execution.
127    #[error("draft_execution_failed: {draft}")]
128    Execution {
129        /// The draft at which the runner stopped.
130        draft: String,
131        /// The preserved product or digest error.
132        #[source]
133        source: ExecuteError<E>,
134    },
135    /// The product outcome exists but Lys did not acknowledge it.
136    #[error("draft_acknowledgment_failed: {draft}")]
137    Record {
138        /// The draft whose product receipt must be reused on retry.
139        draft: String,
140        /// The committed product receipt, never an instruction to execute again.
141        receipt: Box<Execution>,
142        /// The live acknowledgment failure.
143        #[source]
144        source: Error,
145    },
146}
147
148/// One explicit pull's results, with no background polling or automatic retry.
149#[derive(Debug)]
150pub struct RunReport {
151    /// Draft outcomes successfully acknowledged by Lys, including receipt replays.
152    pub acknowledged: usize,
153    /// The next page to pull explicitly, or the end.
154    pub next: Option<String>,
155}
156
157/// Hash the exact prepared words without canonicalizing or changing them.
158#[must_use]
159pub fn request_digest(words: &str) -> String {
160    let mut text = String::with_capacity(64);
161    let digit = |nibble: u8| {
162        char::from(if nibble < 10 {
163            b'0' + nibble
164        } else {
165            b'a' + nibble - 10
166        })
167    };
168    for byte in Sha256::digest(words.as_bytes()) {
169        text.push(digit(byte >> 4));
170        text.push(digit(byte & 15));
171    }
172    text
173}
174
175/// Return a matching durable receipt before ever invoking a product again.
176pub fn execute_once<E: Executor>(
177    draft: &ApprovedDraft,
178    executor: &mut E,
179) -> Result<Execution, ExecuteError<E::Error>> {
180    validate(
181        &draft.grant,
182        &draft.target,
183        &draft.request_digest,
184        &draft.words,
185    )
186    .map_err(ExecuteError::Contract)?;
187    if draft.id.is_empty() || !crate::rights::audience_owns(&draft.target.kind, &draft.app) {
188        return Err(ExecuteError::Contract(Error::Invalid(
189            "draft audience or id differs",
190        )));
191    }
192    let receipt = match executor.receipt(&draft.id).map_err(ExecuteError::Product)? {
193        Some(receipt) => receipt,
194        None => executor
195            .execute_and_record(draft)
196            .map_err(ExecuteError::Product)?,
197    };
198    if receipt.request_digest() != draft.request_digest {
199        return Err(ExecuteError::DigestMismatch);
200    }
201    if let Execution::Executed { receipt_digest, .. } = &receipt
202        && (receipt_digest.len() != 64
203            || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
204    {
205        return Err(ExecuteError::Contract(Error::Invalid(
206            "product receipt digest is malformed",
207        )));
208    }
209    Ok(receipt)
210}
211
212fn validate(grant: &str, target: &Target, digest: &str, words: &str) -> Result<(), Error> {
213    Target::new(&target.kind, &target.id, &target.action)?;
214    if grant.is_empty() || request_digest(words) != digest {
215        return Err(Error::Invalid("draft words or granting receipt differ"));
216    }
217    Ok(())
218}
219
220#[cfg(feature = "http")]
221impl Client {
222    /// Pull one approved page, invoke the durable executor once per draft, and close each outcome.
223    pub async fn run_approved<E: Executor + Send>(
224        &self,
225        connector_pass: &str,
226        app: &str,
227        after: Option<&str>,
228        executor: &mut E,
229    ) -> Result<RunReport, RunError<E::Error>> {
230        let page = self
231            .approved_drafts(connector_pass, app, after)
232            .await
233            .map_err(RunError::Pull)?;
234        let mut report = RunReport {
235            acknowledged: 0,
236            next: page.next,
237        };
238        for draft in &page.drafts {
239            let receipt = execute_once(draft, executor).map_err(|source| RunError::Execution {
240                draft: draft.id.clone(),
241                source,
242            })?;
243            self.record_execution(connector_pass, draft, &receipt)
244                .await
245                .map_err(|source| RunError::Record {
246                    draft: draft.id.clone(),
247                    receipt: Box::new(receipt),
248                    source,
249                })?;
250            report.acknowledged += 1;
251        }
252        Ok(report)
253    }
254
255    /// Record a held act using its caller's pass; an ordinary refusal is never a draft.
256    pub async fn create_draft(
257        &self,
258        pass: &str,
259        draft: &DraftRequest,
260        judgment: &crate::deliberate::CheckAnswer,
261    ) -> Result<DraftCreated, Error> {
262        if judgment.allowed
263            || !matches!(
264                judgment.mode,
265                Some(crate::Mode::ByDraft | crate::Mode::ByTwo)
266            )
267            || judgment.grant.as_deref() != Some(draft.grant.as_str())
268            || judgment.refusal.is_some()
269        {
270            return Err(Error::Invalid("only a held-mode right may create a draft"));
271        }
272        validate(
273            &draft.grant,
274            &draft.target,
275            &draft.request_digest,
276            &draft.words,
277        )?;
278        if pass.is_empty() || draft.operation.is_empty() {
279            return Err(Error::Invalid("draft caller pass or operation is missing"));
280        }
281        let response = self
282            .http
283            .post(self.endpoint("product-drafts")?)
284            .bearer_auth(pass)
285            .json(draft)
286            .send()
287            .await
288            .map_err(|error| Error::Transport(Box::new(error)))?;
289        let created: DraftCreated = response
290            .error_for_status()
291            .map_err(|error| Error::Transport(Box::new(error)))?
292            .json()
293            .await
294            .map_err(|error| Error::Transport(Box::new(error)))?;
295        if created.draft.is_empty() {
296            return Err(Error::CannotAsk("draft id is missing"));
297        }
298        Ok(created)
299    }
300
301    /// Pull one page with the product connector, without a timer or background loop.
302    pub async fn approved_drafts(
303        &self,
304        connector_pass: &str,
305        app: &str,
306        after: Option<&str>,
307    ) -> Result<ApprovedPage, Error> {
308        if connector_pass.is_empty() || app.is_empty() {
309            return Err(Error::Invalid("draft connector or app is missing"));
310        }
311        let mut url = self.endpoint("product-drafts")?;
312        url.query_pairs_mut()
313            .append_pair("app", app)
314            .append_pair("state", "approved");
315        if let Some(after) = after {
316            url.query_pairs_mut().append_pair("after", after);
317        }
318        let response = self
319            .http
320            .get(url)
321            .bearer_auth(connector_pass)
322            .send()
323            .await
324            .map_err(|error| Error::Transport(Box::new(error)))?;
325        let page: ApprovedPage = response
326            .error_for_status()
327            .map_err(|error| Error::Transport(Box::new(error)))?
328            .json()
329            .await
330            .map_err(|error| Error::Transport(Box::new(error)))?;
331        if page.total < page.drafts.len()
332            || after.is_some_and(|cursor| page.next.as_deref() == Some(cursor))
333        {
334            return Err(Error::CannotAsk("draft population or continuation differs"));
335        }
336        let mut ids = std::collections::BTreeSet::new();
337        for draft in &page.drafts {
338            validate(
339                &draft.grant,
340                &draft.target,
341                &draft.request_digest,
342                &draft.words,
343            )?;
344            if draft.app != app
345                || draft.id.is_empty()
346                || !crate::rights::audience_owns(&draft.target.kind, app)
347                || !ids.insert(&draft.id)
348            {
349                return Err(Error::CannotAsk("approved draft belongs to another app"));
350            }
351        }
352        Ok(page)
353    }
354
355    /// Acknowledge the product's durable outcome; failure leaves its local receipt intact.
356    pub async fn record_execution(
357        &self,
358        connector_pass: &str,
359        draft: &ApprovedDraft,
360        receipt: &Execution,
361    ) -> Result<(), Error> {
362        if connector_pass.is_empty() || receipt.request_digest() != draft.request_digest {
363            return Err(Error::Invalid("execution receipt differs from draft"));
364        }
365        let route = match receipt {
366            Execution::Executed { .. } => "executed",
367            Execution::RefusedOnExecution { .. } => "refused-on-execution",
368        };
369        if draft.id.is_empty() {
370            return Err(Error::Invalid("draft id is missing"));
371        }
372        let mut url = self.endpoint("product-drafts/")?;
373        url.path_segments_mut()
374            .map_err(|()| Error::Invalid("issuer cannot hold draft paths"))?
375            .pop_if_empty()
376            .push(&draft.id)
377            .push(route);
378        let body = match receipt {
379            Execution::Executed { receipt_digest, .. } => {
380                if receipt_digest.len() != 64
381                    || !receipt_digest.bytes().all(|byte| byte.is_ascii_hexdigit())
382                {
383                    return Err(Error::Invalid("execution receipt digest is malformed"));
384                }
385                serde_json::json!({ "receipt_digest": receipt_digest })
386            }
387            Execution::RefusedOnExecution {
388                refusal, reason, ..
389            } => {
390                if refusal.is_empty() || reason.is_empty() {
391                    return Err(Error::Invalid(
392                        "execution refusal needs a name and a reason",
393                    ));
394                }
395                serde_json::json!({ "refusal": refusal, "reason": reason })
396            }
397        };
398        self.http
399            .post(url)
400            .bearer_auth(connector_pass)
401            .json(&body)
402            .send()
403            .await
404            .map_err(|error| Error::Transport(Box::new(error)))?
405            .error_for_status()
406            .map_err(|error| Error::Transport(Box::new(error)))?;
407        Ok(())
408    }
409}