use std::collections::BTreeSet;
use std::num::NonZeroUsize;
use lys_pass::drafts::{ApprovedDraft, Execution, Executor};
use serde::{Deserialize, Serialize};
#[path = "../http/admin_drafts.rs"]
pub mod admin;
#[path = "grant_stream.rs"]
mod pulls;
#[path = "../agents/waiting.rs"]
pub mod waiting;
pub use admin::DraftPuller;
pub use projection::AgentProjection;
pub use pulls::AgentDraftStore;
pub use target::TargetDraftStore;
#[path = "drafts_projection.rs"]
mod projection;
#[path = "drafts_target.rs"]
mod target;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DraftLimits {
pub pages: NonZeroUsize,
pub drafts: NonZeroUsize,
pub approvers: NonZeroUsize,
pub words_bytes: NonZeroUsize,
pub metadata_bytes: NonZeroUsize,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalEvidence {
pub draft: String,
pub digest: String,
pub approved_by: Vec<String>,
pub mode: super::super::GrantMode,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalTransition {
pub draft: String,
pub digest: String,
pub approved_by: Vec<String>,
pub executed_at_ms: u64,
}
pub trait AtomicDraftStore: Send {
fn receipt(&self, draft: &str) -> Result<Option<Execution>, DraftError>;
fn approval(&self, draft: &ApprovedDraft) -> Result<ApprovalEvidence, DraftError>;
fn commit(
&mut self,
draft: &ApprovedDraft,
transition: &ApprovalTransition,
) -> Result<Execution, DraftError>;
fn acknowledged(&self, draft: &str) -> Result<bool, DraftError>;
fn acknowledge(&mut self, draft: &str, receipt: &Execution) -> Result<(), DraftError>;
fn pending_acknowledgments(
&self,
limit: NonZeroUsize,
) -> Result<Vec<ApprovedDraft>, DraftError>;
fn deliver_outcome(
&mut self,
draft: &ApprovedDraft,
receipt: &Execution,
) -> Result<(), DraftError>;
}
#[derive(Debug, thiserror::Error)]
pub enum DraftError {
#[error("draft_lys_failed: {0}")]
Lys(String),
#[error("draft_bound_exceeded")]
Bound,
#[error("draft_cursor_repeated")]
CursorRepeated,
#[error("draft_id_repeated")]
IdRepeated,
#[error("draft_approval_evidence_missing")]
ApprovalUnavailable,
#[error("draft_receipt_digest_mismatch")]
ReceiptMismatch,
#[error("draft_store_failed")]
Store,
#[error("draft_startup_pull_repeated")]
StartupRepeated,
#[error("draft_acknowledgment_failed: {draft}: {name}")]
Acknowledgment {
draft: String,
name: String,
},
#[error("draft_contract_refused")]
Contract,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PullSignal {
Startup,
Console,
Decision,
}
#[must_use]
pub fn stream_signal(kind: &str) -> Option<PullSignal> {
(kind == "draft_decision").then_some(PullSignal::Decision)
}
#[async_trait::async_trait]
pub trait DraftDecisionHook: Send + Sync {
async fn draft_decided(&self) -> Result<PullReport, DraftError>;
}
#[derive(Default, Debug, Serialize)]
pub struct PullReport {
pub pages: usize,
pub executed: usize,
pub refused: usize,
pub replayed: usize,
pub acknowledged: usize,
}
pub struct DraftExecutor<S> {
pub(crate) store: S,
pub(crate) limits: DraftLimits,
pub(crate) at_ms: u64,
}
impl<S: AtomicDraftStore> Executor for DraftExecutor<S> {
type Error = DraftError;
fn receipt(&self, draft: &str) -> Result<Option<Execution>, DraftError> {
self.store.receipt(draft)
}
fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, DraftError> {
let evidence = self.store.approval(draft)?;
let mut people = BTreeSet::new();
let needed = match evidence.mode {
super::super::GrantMode::ByDraft => 1,
super::super::GrantMode::ByTwo => 2,
super::super::GrantMode::Outright => return Err(DraftError::ApprovalUnavailable),
};
if evidence.draft != draft.id
|| evidence.digest != draft.request_digest
|| evidence.approved_by.len() != needed
|| evidence.approved_by.len() > self.limits.approvers.get()
|| evidence.approved_by.iter().any(|person| {
!valid_metadata(person, self.limits.metadata_bytes) || !people.insert(person)
})
{
return Err(DraftError::ApprovalUnavailable);
}
self.store.commit(
draft,
&ApprovalTransition {
draft: draft.id.clone(),
digest: draft.request_digest.clone(),
approved_by: evidence.approved_by,
executed_at_ms: self.at_ms,
},
)
}
}
pub(crate) fn valid_metadata(value: &str, bound: NonZeroUsize) -> bool {
!value.is_empty() && value.len() <= bound.get() && !value.chars().any(char::is_control)
}
pub(crate) fn validate_bounds(
draft: &ApprovedDraft,
limits: DraftLimits,
) -> Result<(), DraftError> {
bounded(
&draft.id,
&draft.app,
&draft.grant,
&draft.target,
&draft.words,
limits,
)
}
pub(crate) fn validate_request_bounds(
request: &lys_pass::drafts::DraftRequest,
limits: DraftLimits,
) -> Result<(), DraftError> {
bounded(
&request.operation,
"liminal",
&request.grant,
&request.target,
&request.words,
limits,
)
}
fn bounded(
id: &str,
app: &str,
grant: &str,
target: &lys_pass::Target,
words: &str,
limits: DraftLimits,
) -> Result<(), DraftError> {
if words.is_empty()
|| words.len() > limits.words_bytes.get()
|| [id, app, grant, &target.kind, &target.id, &target.action]
.iter()
.any(|value| !valid_metadata(value, limits.metadata_bytes))
{
return Err(DraftError::Bound);
}
Ok(())
}
#[must_use]
pub fn execution_digest(receipt: &Execution) -> &str {
match receipt {
Execution::Executed { request_digest, .. }
| Execution::RefusedOnExecution { request_digest, .. } => request_digest,
}
}
#[must_use]
pub fn same_request(request: &lys_pass::drafts::DraftRequest, draft: &ApprovedDraft) -> bool {
draft.app == "liminal"
&& request.grant == draft.grant
&& request.target == draft.target
&& request.request_digest == draft.request_digest
&& request.words == draft.words
}
pub fn issuer_approval(
journal: &waiting::durable::DraftJournal,
draft: &ApprovedDraft,
) -> Result<ApprovalEvidence, DraftError> {
let mode = match draft.mode {
lys_pass::Mode::Outright => super::super::GrantMode::Outright,
lys_pass::Mode::ByDraft => super::super::GrantMode::ByDraft,
lys_pass::Mode::ByTwo => super::super::GrantMode::ByTwo,
};
let prepared = journal.read(|snapshot| {
snapshot
.prepared
.values()
.find(|prepared| prepared.draft.as_deref() == Some(&draft.id))
.map(|prepared| prepared.mode)
})?;
if prepared.is_some_and(|prepared| prepared != mode) {
return Err(DraftError::ApprovalUnavailable);
}
Ok(ApprovalEvidence {
draft: draft.id.clone(),
digest: draft.request_digest.clone(),
approved_by: draft.approved_by.clone(),
mode,
})
}
impl crate::services::lifecycle::site::executor::ApprovalReader for waiting::durable::DraftJournal {
fn read(&self, draft: &ApprovedDraft) -> Result<ApprovalEvidence, DraftError> {
issuer_approval(self, draft)
}
}
pub(super) fn block<T, F>(handle: &tokio::runtime::Handle, future: F) -> Result<T, lys_pass::Error>
where
T: Send,
F: std::future::Future<Output = Result<T, lys_pass::Error>> + Send,
{
std::thread::scope(|scope| scope.spawn(move || handle.block_on(future)).join())
.unwrap_or_else(|_| Err(lys_pass::Error::CannotAsk("the live ask did not complete")))
}
pub(super) fn prepared_failure(_: DraftError) -> lys_pass::Error {
lys_pass::Error::CannotAsk("draft target journal failed")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JournalLimits {
pub rows: NonZeroUsize,
pub operations: NonZeroUsize,
pub snapshot_bytes: NonZeroUsize,
pub replay_page_rows: NonZeroUsize,
}
impl AgentDraftStore {
#[must_use]
pub fn with_projection(mut self, projection: std::sync::Arc<dyn AgentProjection>) -> Self {
self.projection = projection;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DraftConfig {
pub pull: DraftLimits,
pub journal: JournalLimits,
pub waiting: waiting::WaitingLimits,
pub http: admin::AdminLimits,
}
#[cfg(test)]
#[path = "drafts_tests.rs"]
pub(crate) mod tests;