1use std::collections::BTreeSet;
7use std::num::NonZeroUsize;
8
9use lys_pass::drafts::{ApprovedDraft, Execution, Executor};
10use serde::{Deserialize, Serialize};
11
12#[path = "../http/admin_drafts.rs"]
13pub mod admin;
14#[path = "grant_stream.rs"]
15mod pulls;
16#[path = "../agents/waiting.rs"]
17pub mod waiting;
18pub use admin::DraftPuller;
19pub use projection::AgentProjection;
20pub use pulls::AgentDraftStore;
21pub use target::TargetDraftStore;
22#[path = "drafts_projection.rs"]
23mod projection;
24#[path = "drafts_target.rs"]
25mod target;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct DraftLimits {
31 pub pages: NonZeroUsize,
34 pub drafts: NonZeroUsize,
37 pub approvers: NonZeroUsize,
40 pub words_bytes: NonZeroUsize,
42 pub metadata_bytes: NonZeroUsize,
44}
45
46#[derive(Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ApprovalEvidence {
50 pub draft: String,
52 pub digest: String,
54 pub approved_by: Vec<String>,
56 pub mode: super::super::GrantMode,
58}
59
60#[derive(Clone, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct ApprovalTransition {
64 pub draft: String,
66 pub digest: String,
68 pub approved_by: Vec<String>,
70 pub executed_at_ms: u64,
72}
73
74pub trait AtomicDraftStore: Send {
82 fn receipt(&self, draft: &str) -> Result<Option<Execution>, DraftError>;
87
88 fn approval(&self, draft: &ApprovedDraft) -> Result<ApprovalEvidence, DraftError>;
93
94 fn commit(
103 &mut self,
104 draft: &ApprovedDraft,
105 transition: &ApprovalTransition,
106 ) -> Result<Execution, DraftError>;
107
108 fn acknowledged(&self, draft: &str) -> Result<bool, DraftError>;
113
114 fn acknowledge(&mut self, draft: &str, receipt: &Execution) -> Result<(), DraftError>;
120
121 fn pending_acknowledgments(
127 &self,
128 limit: NonZeroUsize,
129 ) -> Result<Vec<ApprovedDraft>, DraftError>;
130
131 fn deliver_outcome(
138 &mut self,
139 draft: &ApprovedDraft,
140 receipt: &Execution,
141 ) -> Result<(), DraftError>;
142}
143
144#[derive(Debug, thiserror::Error)]
146pub enum DraftError {
147 #[error("draft_lys_failed: {0}")]
149 Lys(String),
150 #[error("draft_bound_exceeded")]
152 Bound,
153 #[error("draft_cursor_repeated")]
155 CursorRepeated,
156 #[error("draft_id_repeated")]
158 IdRepeated,
159 #[error("draft_approval_evidence_missing")]
161 ApprovalUnavailable,
162 #[error("draft_receipt_digest_mismatch")]
164 ReceiptMismatch,
165 #[error("draft_store_failed")]
167 Store,
168 #[error("draft_startup_pull_repeated")]
170 StartupRepeated,
171 #[error("draft_acknowledgment_failed: {draft}: {name}")]
173 Acknowledgment {
174 draft: String,
176 name: String,
178 },
179 #[error("draft_contract_refused")]
181 Contract,
182}
183
184#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186pub enum PullSignal {
187 Startup,
189 Console,
191 Decision,
193}
194
195#[must_use]
198pub fn stream_signal(kind: &str) -> Option<PullSignal> {
199 (kind == "draft_decision").then_some(PullSignal::Decision)
200}
201
202#[async_trait::async_trait]
204pub trait DraftDecisionHook: Send + Sync {
205 async fn draft_decided(&self) -> Result<PullReport, DraftError>;
210}
211
212#[derive(Default, Debug, Serialize)]
214pub struct PullReport {
215 pub pages: usize,
217 pub executed: usize,
219 pub refused: usize,
221 pub replayed: usize,
223 pub acknowledged: usize,
225}
226
227pub struct DraftExecutor<S> {
229 pub(crate) store: S,
230 pub(crate) limits: DraftLimits,
231 pub(crate) at_ms: u64,
232}
233
234impl<S: AtomicDraftStore> Executor for DraftExecutor<S> {
235 type Error = DraftError;
236
237 fn receipt(&self, draft: &str) -> Result<Option<Execution>, DraftError> {
238 self.store.receipt(draft)
239 }
240
241 fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, DraftError> {
242 let evidence = self.store.approval(draft)?;
243 let mut people = BTreeSet::new();
244 let needed = match evidence.mode {
245 super::super::GrantMode::ByDraft => 1,
246 super::super::GrantMode::ByTwo => 2,
247 super::super::GrantMode::Outright => return Err(DraftError::ApprovalUnavailable),
248 };
249 if evidence.draft != draft.id
250 || evidence.digest != draft.request_digest
251 || evidence.approved_by.len() != needed
252 || evidence.approved_by.len() > self.limits.approvers.get()
253 || evidence.approved_by.iter().any(|person| {
254 !valid_metadata(person, self.limits.metadata_bytes) || !people.insert(person)
255 })
256 {
257 return Err(DraftError::ApprovalUnavailable);
258 }
259 self.store.commit(
260 draft,
261 &ApprovalTransition {
262 draft: draft.id.clone(),
263 digest: draft.request_digest.clone(),
264 approved_by: evidence.approved_by,
265 executed_at_ms: self.at_ms,
266 },
267 )
268 }
269}
270
271pub(crate) fn valid_metadata(value: &str, bound: NonZeroUsize) -> bool {
272 !value.is_empty() && value.len() <= bound.get() && !value.chars().any(char::is_control)
273}
274
275pub(crate) fn validate_bounds(
276 draft: &ApprovedDraft,
277 limits: DraftLimits,
278) -> Result<(), DraftError> {
279 bounded(
280 &draft.id,
281 &draft.app,
282 &draft.grant,
283 &draft.target,
284 &draft.words,
285 limits,
286 )
287}
288
289pub(crate) fn validate_request_bounds(
291 request: &lys_pass::drafts::DraftRequest,
292 limits: DraftLimits,
293) -> Result<(), DraftError> {
294 bounded(
295 &request.operation,
296 "liminal",
297 &request.grant,
298 &request.target,
299 &request.words,
300 limits,
301 )
302}
303
304fn bounded(
305 id: &str,
306 app: &str,
307 grant: &str,
308 target: &lys_pass::Target,
309 words: &str,
310 limits: DraftLimits,
311) -> Result<(), DraftError> {
312 if words.is_empty()
313 || words.len() > limits.words_bytes.get()
314 || [id, app, grant, &target.kind, &target.id, &target.action]
315 .iter()
316 .any(|value| !valid_metadata(value, limits.metadata_bytes))
317 {
318 return Err(DraftError::Bound);
319 }
320 Ok(())
321}
322
323#[must_use]
325pub fn execution_digest(receipt: &Execution) -> &str {
326 match receipt {
327 Execution::Executed { request_digest, .. }
328 | Execution::RefusedOnExecution { request_digest, .. } => request_digest,
329 }
330}
331
332#[must_use]
334pub fn same_request(request: &lys_pass::drafts::DraftRequest, draft: &ApprovedDraft) -> bool {
335 draft.app == "liminal"
336 && request.grant == draft.grant
337 && request.target == draft.target
338 && request.request_digest == draft.request_digest
339 && request.words == draft.words
340}
341
342pub fn issuer_approval(
348 journal: &waiting::durable::DraftJournal,
349 draft: &ApprovedDraft,
350) -> Result<ApprovalEvidence, DraftError> {
351 let mode = match draft.mode {
356 lys_pass::Mode::Outright => super::super::GrantMode::Outright,
357 lys_pass::Mode::ByDraft => super::super::GrantMode::ByDraft,
358 lys_pass::Mode::ByTwo => super::super::GrantMode::ByTwo,
359 };
360 let prepared = journal.read(|snapshot| {
361 snapshot
362 .prepared
363 .values()
364 .find(|prepared| prepared.draft.as_deref() == Some(&draft.id))
365 .map(|prepared| prepared.mode)
366 })?;
367 if prepared.is_some_and(|prepared| prepared != mode) {
368 return Err(DraftError::ApprovalUnavailable);
369 }
370 Ok(ApprovalEvidence {
371 draft: draft.id.clone(),
372 digest: draft.request_digest.clone(),
373 approved_by: draft.approved_by.clone(),
374 mode,
375 })
376}
377
378impl crate::services::lifecycle::site::executor::ApprovalReader for waiting::durable::DraftJournal {
379 fn read(&self, draft: &ApprovedDraft) -> Result<ApprovalEvidence, DraftError> {
380 issuer_approval(self, draft)
381 }
382}
383
384pub(super) fn block<T, F>(handle: &tokio::runtime::Handle, future: F) -> Result<T, lys_pass::Error>
386where
387 T: Send,
388 F: std::future::Future<Output = Result<T, lys_pass::Error>> + Send,
389{
390 std::thread::scope(|scope| scope.spawn(move || handle.block_on(future)).join())
391 .unwrap_or_else(|_| Err(lys_pass::Error::CannotAsk("the live ask did not complete")))
392}
393
394pub(super) fn prepared_failure(_: DraftError) -> lys_pass::Error {
395 lys_pass::Error::CannotAsk("draft target journal failed")
396}
397
398#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub struct JournalLimits {
402 pub rows: NonZeroUsize,
404 pub operations: NonZeroUsize,
406 pub snapshot_bytes: NonZeroUsize,
408 pub replay_page_rows: NonZeroUsize,
410}
411
412impl AgentDraftStore {
413 #[must_use]
415 pub fn with_projection(mut self, projection: std::sync::Arc<dyn AgentProjection>) -> Self {
416 self.projection = projection;
417 self
418 }
419}
420
421#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct DraftConfig {
425 pub pull: DraftLimits,
426 pub journal: JournalLimits,
427 pub waiting: waiting::WaitingLimits,
428 pub http: admin::AdminLimits,
429}
430
431#[cfg(test)]
432#[path = "drafts_tests.rs"]
433pub(crate) mod tests;