Skip to main content

liminal_server/agents/
waiting.rs

1//! Retained waiting and outcome messages, changed in the agent's own store.
2
3use std::collections::BTreeMap;
4use std::num::NonZeroUsize;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8
9#[path = "waiting_state.rs"]
10pub mod durable;
11
12/// Bounds on one agent's retained approval history.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct WaitingLimits {
16    /// Bounds pending drafts and resolved ids together, preserving deduplication
17    /// without evicting evidence. A full record refuses another held act.
18    pub drafts: NonZeroUsize,
19    /// Bounds exact approver words; an oversized draft is refused, never cut.
20    pub words_bytes: NonZeroUsize,
21    /// Bounds identifiers used to form retained-state and inbox subjects.
22    pub identifier_bytes: NonZeroUsize,
23}
24
25#[derive(Clone, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct HeldDraft {
28    draft: String,
29    words: String,
30    since: u64,
31}
32
33#[derive(Clone, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct ResolvedDraft {
36    digest: String,
37    outcome: DraftOutcome,
38}
39
40/// The same outcome is retained on state and published on the inbox.
41#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct DraftOutcome {
44    /// The immutable draft id.
45    pub draft: String,
46    /// Executed or refused; never an authority decision made here.
47    pub outcome: OutcomeKind,
48    /// The named execution refusal, if any.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub refusal: Option<String>,
51    /// The refusing authority's exact reason, unchanged.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub reason: Option<String>,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum OutcomeKind {
59    Executed,
60    Refused,
61}
62
63impl DraftOutcome {
64    /// An execution already committed by the target store.
65    #[must_use]
66    pub fn executed(draft: &str) -> Self {
67        Self {
68            draft: draft.to_owned(),
69            outcome: OutcomeKind::Executed,
70            refusal: None,
71            reason: None,
72        }
73    }
74
75    /// A refusal already committed by the authority or target store.
76    #[must_use]
77    pub fn refused(draft: &str, refusal: &str, reason: &str) -> Self {
78        Self {
79            draft: draft.to_owned(),
80            outcome: OutcomeKind::Refused,
81            refusal: Some(refusal.to_owned()),
82            reason: Some(reason.to_owned()),
83        }
84    }
85}
86
87#[derive(Clone, Default, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct WaitingRecord {
90    pending: Vec<HeldDraft>,
91    resolved: BTreeMap<String, ResolvedDraft>,
92    last: Option<DraftOutcome>,
93    gone: bool,
94}
95
96impl std::fmt::Debug for WaitingRecord {
97    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        formatter
99            .debug_struct("WaitingRecord")
100            .finish_non_exhaustive()
101    }
102}
103
104/// Named failures contain no held words or credential bytes.
105#[derive(Debug, thiserror::Error)]
106pub enum WaitingError {
107    /// An input does not fit the configured retained record.
108    #[error("agent_waiting_bound")]
109    Bound,
110    /// The same draft id was presented with different words or outcome.
111    #[error("agent_waiting_draft_conflict")]
112    Conflict,
113    /// No held draft has this id.
114    #[error("agent_waiting_draft_unknown")]
115    Unknown,
116    /// State could not be encoded or durably committed.
117    #[error("agent_waiting_store_failed")]
118    Store,
119}
120
121impl WaitingRecord {
122    /// Retains exact words once, with the earliest held instant.
123    ///
124    /// # Errors
125    /// Invalid ids, empty or oversized words, capacity, or conflicting replay.
126    pub fn hold(
127        &mut self,
128        draft: &str,
129        words: &str,
130        since: u64,
131        limits: WaitingLimits,
132    ) -> Result<bool, WaitingError> {
133        if draft.is_empty()
134            || draft.len() > limits.identifier_bytes.get()
135            || draft.chars().any(char::is_control)
136            || words.is_empty()
137            || words.len() > limits.words_bytes.get()
138        {
139            return Err(WaitingError::Bound);
140        }
141        if let Some(previous) = self.resolved.get(draft) {
142            return if previous.digest == lys_pass::drafts::request_digest(words) {
143                Ok(false)
144            } else {
145                Err(WaitingError::Conflict)
146            };
147        }
148        if let Some(held) = self.pending.iter().find(|held| held.draft == draft) {
149            return if held.words == words {
150                Ok(false)
151            } else {
152                Err(WaitingError::Conflict)
153            };
154        }
155        if self.pending.len().saturating_add(self.resolved.len()) >= limits.drafts.get() {
156            return Err(WaitingError::Bound);
157        }
158        self.pending.push(HeldDraft {
159            draft: draft.to_owned(),
160            words: words.to_owned(),
161            since,
162        });
163        self.pending
164            .sort_by(|left, right| (left.since, &left.draft).cmp(&(right.since, &right.draft)));
165        Ok(true)
166    }
167
168    /// Completes one held draft and returns its inbox message once.
169    /// The caller commits this record, retained state and inbox together.
170    ///
171    /// # Errors
172    /// Unknown or conflicting draft, invalid outcome, or missing provenance.
173    pub fn resolve(
174        &mut self,
175        outcome: &DraftOutcome,
176        exchange: &str,
177        gone: bool,
178        limits: WaitingLimits,
179    ) -> Result<Option<Value>, WaitingError> {
180        if exchange.len() > limits.identifier_bytes.get()
181            || outcome.draft.len() > limits.identifier_bytes.get()
182            || outcome
183                .refusal
184                .as_ref()
185                .is_some_and(|name| name.len() > limits.identifier_bytes.get())
186            || outcome
187                .reason
188                .as_ref()
189                .is_some_and(|reason| reason.len() > limits.words_bytes.get())
190        {
191            return Err(WaitingError::Bound);
192        }
193        if exchange.is_empty()
194            || exchange.chars().any(char::is_control)
195            || outcome.draft.is_empty()
196            || outcome.draft.chars().any(char::is_control)
197            || outcome
198                .refusal
199                .as_ref()
200                .is_some_and(|name| name.chars().any(char::is_control))
201            || matches!(outcome.outcome, OutcomeKind::Refused)
202                && (outcome.refusal.as_deref().is_none_or(str::is_empty)
203                    || outcome.reason.as_deref().is_none_or(str::is_empty))
204            || matches!(outcome.outcome, OutcomeKind::Executed)
205                && (outcome.refusal.is_some() || outcome.reason.is_some())
206        {
207            return Err(WaitingError::Conflict);
208        }
209        if let Some(previous) = self.resolved.get(&outcome.draft) {
210            return if &previous.outcome == outcome {
211                Ok(None)
212            } else {
213                Err(WaitingError::Conflict)
214            };
215        }
216        let position = self
217            .pending
218            .iter()
219            .position(|held| held.draft == outcome.draft)
220            .ok_or(WaitingError::Unknown)?;
221        let mut inbox = serde_json::to_value(outcome).map_err(|_| WaitingError::Store)?;
222        inbox["meta"] = json!({"from":exchange});
223        let held = self.pending.remove(position);
224        self.resolved.insert(
225            outcome.draft.clone(),
226            ResolvedDraft {
227                digest: lys_pass::drafts::request_digest(&held.words),
228                outcome: outcome.clone(),
229            },
230        );
231        self.last = Some(outcome.clone());
232        self.gone = gone;
233        Ok(Some(inbox))
234    }
235
236    /// The oldest pending draft and total count, or the last terminal outcome.
237    ///
238    /// # Errors
239    /// The terminal outcome could not be encoded.
240    pub fn snapshot(&self) -> Result<Value, WaitingError> {
241        if let Some(held) = self.pending.first() {
242            return Ok(json!({"state":"waiting", "draft":held.draft,
243                "words":held.words, "since":held.since, "count":self.pending.len(), "last_outcome":self.last}));
244        }
245        let mut value = match &self.last {
246            Some(last) => serde_json::to_value(last).map_err(|_| WaitingError::Store)?,
247            None => json!({}),
248        };
249        value["state"] = json!(if self.gone { "gone" } else { "present" });
250        Ok(value)
251    }
252}
253
254use crate::agents::runtime::Agents;
255use crate::auth_lys::live::drafts::DraftError;
256use durable::DraftJournal;
257use liminal::channel::ChannelHandle;
258use lys_pass::drafts::{ApprovedDraft, Execution};
259use std::sync::{Arc, Mutex};
260
261#[derive(Clone, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct Delivery {
264    agent: String,
265    from: Vec<u64>,
266    message: Value,
267    state: Value,
268    delivered: bool,
269}
270/// Publishes the durable outbox to the verified holder's server-resolved inbox.
271pub struct AgentDelivery {
272    agents: Arc<Agents>,
273    journal: Arc<DraftJournal>,
274    inboxes: Mutex<BTreeMap<String, ChannelHandle>>,
275    exchange: String,
276    limits: WaitingLimits,
277    serial: Mutex<()>,
278}
279impl AgentDelivery {
280    /// Exact native durable inboxes share the configured retained-history population bound.
281    ///
282    /// # Errors
283    /// Invalid station, mismatched/non-durable inbox or configured capacity.
284    pub fn new(
285        owners: &durable::ApprovalAgents,
286        inboxes: BTreeMap<String, ChannelHandle>,
287        exchange: String,
288        limits: WaitingLimits,
289    ) -> Result<Arc<Self>, DraftError> {
290        if inboxes.len() > limits.drafts.get()
291            || exchange.is_empty()
292            || exchange.len() > limits.identifier_bytes.get()
293            || exchange.chars().any(char::is_control)
294        {
295            return Err(DraftError::Bound);
296        }
297        for (name, inbox) in &inboxes {
298            owners
299                .agents()
300                .registry()
301                .record(name)
302                .map_err(|_| DraftError::Contract)?;
303            if inbox.config().name != format!("agents.{name}.inbox") {
304                return Err(DraftError::Contract);
305            }
306            inbox
307                .durable_positions()
308                .map_err(|_| DraftError::Contract)?;
309        }
310        Ok(Arc::new(Self {
311            agents: owners.agents().clone(),
312            journal: owners.journal().clone(),
313            inboxes: Mutex::new(inboxes),
314            exchange,
315            limits,
316            serial: Mutex::new(()),
317        }))
318    }
319    fn message(
320        &self,
321        agent: &str,
322        draft: &ApprovedDraft,
323        receipt: &Execution,
324        from: Vec<u64>,
325    ) -> Result<Delivery, DraftError> {
326        let outcome = match receipt {
327            Execution::Executed { .. } => DraftOutcome::executed(&draft.id),
328            Execution::RefusedOnExecution {
329                refusal, reason, ..
330            } => DraftOutcome::refused(&draft.id, refusal, reason),
331        };
332        let gone = self
333            .agents
334            .presence()
335            .connection(agent)
336            .map_err(|_| DraftError::Store)?
337            .is_none();
338        self.journal.update(|snapshot| {
339            if let Some(old) = snapshot.deliveries.get(&draft.id) {
340                return Ok(old.clone());
341            }
342            let waiting = snapshot
343                .waiting
344                .get_mut(agent)
345                .ok_or(DraftError::Contract)?;
346            let message = waiting
347                .resolve(&outcome, &self.exchange, gone, self.limits)
348                .map_err(|_| DraftError::Contract)?
349                .ok_or(DraftError::Contract)?;
350            let delivery = Delivery {
351                agent: agent.into(),
352                from,
353                message,
354                state: waiting.snapshot().map_err(|_| DraftError::Store)?,
355                delivered: false,
356            };
357            snapshot
358                .deliveries
359                .insert(draft.id.clone(), delivery.clone());
360            Ok(delivery)
361        })
362    }
363    /// Binds a newly registered native inbox without rebuilding the outbox owner.
364    ///
365    /// # Errors
366    /// Unknown station, wrong/non-durable channel, unavailable index or capacity.
367    pub fn bind_inbox(&self, name: &str, inbox: ChannelHandle) -> Result<(), DraftError> {
368        self.agents
369            .registry()
370            .record(name)
371            .map_err(|_| DraftError::Contract)?;
372        if inbox.config().name != format!("agents.{name}.inbox") {
373            return Err(DraftError::Contract);
374        }
375        inbox
376            .durable_positions()
377            .map_err(|_| DraftError::Contract)?;
378        let mut inboxes = self.inboxes.lock().map_err(|_| DraftError::Store)?;
379        if inboxes.contains_key(name) {
380            return Ok(());
381        }
382        if inboxes.len() >= self.limits.drafts.get() {
383            return Err(DraftError::Bound);
384        }
385        inboxes.insert(name.into(), inbox);
386        drop(inboxes);
387        Ok(())
388    }
389}
390#[path = "waiting_outcome.rs"]
391mod outcome;
392
393#[cfg(test)]
394#[path = "waiting_tests.rs"]
395pub(crate) mod tests;
396
397impl std::fmt::Debug for AgentDelivery {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        f.debug_struct("AgentDelivery").finish_non_exhaustive()
400    }
401}