Skip to main content

made_core/entities/
ceremony_agent_status.rs

1use serde::{Deserialize, Serialize};
2use time::{Duration, OffsetDateTime};
3
4use super::{AgentExecutionStatus, AgentLiveness, AgentStatusSource, AgentUsageKind};
5use crate::error::DomainError;
6use crate::value_objects::{
7    CeremonyAgentExecutionId, CeremonyId, ExecutionOperationId, HostAgentIncarnation, LeaseOwnerId,
8    LogicalWorkerId, RoleId, StepClaimFence, StepId,
9};
10
11const MAX_STATUS_TEXT: usize = 512;
12const MAX_EVIDENCE_REFERENCES: usize = 20;
13
14/// Bounded host-owned status evidence for one logical worker execution.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct CeremonyAgentStatus {
17    ceremony_id: CeremonyId,
18    agent_execution_id: CeremonyAgentExecutionId,
19    operation_id: ExecutionOperationId,
20    claim_owner_id: LeaseOwnerId,
21    logical_worker_id: LogicalWorkerId,
22    host_agent_id: LeaseOwnerId,
23    host_agent_incarnation: HostAgentIncarnation,
24    previous_host_agent_id: Option<LeaseOwnerId>,
25    previous_host_agent_incarnation: Option<HostAgentIncarnation>,
26    role_id: RoleId,
27    step_id: StepId,
28    attempt: u32,
29    execution_status: AgentExecutionStatus,
30    liveness: AgentLiveness,
31    source: AgentStatusSource,
32    requested_model: Option<String>,
33    requested_reasoning_effort: Option<String>,
34    actual_model: Option<String>,
35    actual_reasoning_effort: Option<String>,
36    activity: String,
37    blocker: Option<String>,
38    dependency: Option<String>,
39    task_summary: String,
40    evidence_references: Vec<String>,
41    usage_kind: Option<AgentUsageKind>,
42    usage_value: Option<u64>,
43    observed_at: OffsetDateTime,
44    report_sequence: u64,
45    idempotency_key: String,
46    claim_fence: StepClaimFence,
47}
48
49#[allow(clippy::too_many_arguments)]
50impl CeremonyAgentStatus {
51    pub fn new(
52        ceremony_id: CeremonyId,
53        agent_execution_id: CeremonyAgentExecutionId,
54        operation_id: ExecutionOperationId,
55        claim_owner_id: LeaseOwnerId,
56        logical_worker_id: LogicalWorkerId,
57        host_agent_id: LeaseOwnerId,
58        host_agent_incarnation: HostAgentIncarnation,
59        previous_host_agent_id: Option<LeaseOwnerId>,
60        previous_host_agent_incarnation: Option<HostAgentIncarnation>,
61        role_id: RoleId,
62        step_id: StepId,
63        attempt: u32,
64        execution_status: AgentExecutionStatus,
65        liveness: AgentLiveness,
66        source: AgentStatusSource,
67        requested_model: Option<String>,
68        requested_reasoning_effort: Option<String>,
69        actual_model: Option<String>,
70        actual_reasoning_effort: Option<String>,
71        activity: impl Into<String>,
72        blocker: Option<String>,
73        dependency: Option<String>,
74        task_summary: impl Into<String>,
75        evidence_references: Vec<String>,
76        usage_kind: Option<AgentUsageKind>,
77        usage_value: Option<u64>,
78        observed_at: OffsetDateTime,
79        report_sequence: u64,
80        idempotency_key: impl Into<String>,
81        claim_fence: StepClaimFence,
82    ) -> Result<Self, DomainError> {
83        if report_sequence == 0 {
84            return Err(DomainError::MustBeNonZero {
85                field: "report_sequence",
86            });
87        }
88        let status = Self {
89            ceremony_id,
90            agent_execution_id,
91            operation_id,
92            claim_owner_id,
93            logical_worker_id,
94            host_agent_id,
95            host_agent_incarnation,
96            previous_host_agent_id,
97            previous_host_agent_incarnation,
98            role_id,
99            step_id,
100            attempt,
101            execution_status,
102            liveness,
103            source,
104            requested_model: optional_text(requested_model, "requested_model")?,
105            requested_reasoning_effort: optional_text(
106                requested_reasoning_effort,
107                "requested_reasoning_effort",
108            )?,
109            actual_model: optional_text(actual_model, "actual_model")?,
110            actual_reasoning_effort: optional_text(
111                actual_reasoning_effort,
112                "actual_reasoning_effort",
113            )?,
114            activity: text(activity.into(), "activity")?,
115            blocker: optional_text(blocker, "blocker")?,
116            dependency: optional_text(dependency, "dependency")?,
117            task_summary: text(task_summary.into(), "task_summary")?,
118            evidence_references: evidence_references
119                .into_iter()
120                .map(|value| text(value, "evidence_references"))
121                .collect::<Result<_, _>>()?,
122            usage_kind,
123            usage_value,
124            observed_at,
125            report_sequence,
126            idempotency_key: text(idempotency_key.into(), "idempotency_key")?,
127            claim_fence,
128        };
129        if status.previous_host_agent_id.is_some()
130            != status.previous_host_agent_incarnation.is_some()
131        {
132            return Err(DomainError::InvariantViolated {
133                reason: "handoff provenance must include both previous host identities",
134            });
135        }
136        if status.evidence_references.len() > MAX_EVIDENCE_REFERENCES {
137            return Err(DomainError::OutOfRange {
138                field: "evidence_references",
139                value: status.evidence_references.len() as f64,
140                min: 0.0,
141                max: MAX_EVIDENCE_REFERENCES as f64,
142            });
143        }
144        match (status.usage_kind, status.usage_value) {
145            (None | Some(AgentUsageKind::Unavailable), None)
146            | (Some(AgentUsageKind::Measured | AgentUsageKind::Estimated), Some(_)) => {}
147            _ => {
148                return Err(DomainError::InvariantViolated {
149                    reason: "agent usage value must match measured, estimated, or unavailable provenance",
150                });
151            }
152        }
153        // A lease proves only that MADE has not accepted a competing claim. It
154        // says nothing about whether an external host is still reachable.
155        if matches!(status.source, AgentStatusSource::DerivedLease)
156            && matches!(
157                status.liveness,
158                AgentLiveness::Fresh | AgentLiveness::Unreachable
159            )
160        {
161            return Err(DomainError::InvariantViolated {
162                reason: "a derived lease cannot claim host liveness",
163            });
164        }
165        if matches!(status.source, AgentStatusSource::HostDiscoveryUnsupported)
166            && !matches!(status.liveness, AgentLiveness::Unknown)
167        {
168            return Err(DomainError::InvariantViolated {
169                reason: "an unsupported host can only yield an unknown observation",
170            });
171        }
172        Ok(status)
173    }
174
175    #[must_use]
176    pub fn ceremony_id(&self) -> &CeremonyId {
177        &self.ceremony_id
178    }
179    #[must_use]
180    pub fn agent_execution_id(&self) -> &CeremonyAgentExecutionId {
181        &self.agent_execution_id
182    }
183    #[must_use]
184    pub const fn operation_id(&self) -> &ExecutionOperationId {
185        &self.operation_id
186    }
187    #[must_use]
188    pub const fn claim_owner_id(&self) -> &LeaseOwnerId {
189        &self.claim_owner_id
190    }
191    #[must_use]
192    pub fn logical_worker_id(&self) -> &LogicalWorkerId {
193        &self.logical_worker_id
194    }
195    #[must_use]
196    pub fn host_agent_id(&self) -> &LeaseOwnerId {
197        &self.host_agent_id
198    }
199    #[must_use]
200    pub fn host_agent_incarnation(&self) -> &HostAgentIncarnation {
201        &self.host_agent_incarnation
202    }
203    #[must_use]
204    pub fn previous_host_agent_id(&self) -> Option<&LeaseOwnerId> {
205        self.previous_host_agent_id.as_ref()
206    }
207    #[must_use]
208    pub fn previous_host_agent_incarnation(&self) -> Option<&HostAgentIncarnation> {
209        self.previous_host_agent_incarnation.as_ref()
210    }
211    #[must_use]
212    pub fn role_id(&self) -> &RoleId {
213        &self.role_id
214    }
215    #[must_use]
216    pub fn step_id(&self) -> &StepId {
217        &self.step_id
218    }
219    #[must_use]
220    pub fn attempt(&self) -> u32 {
221        self.attempt
222    }
223    #[must_use]
224    pub fn execution_status(&self) -> AgentExecutionStatus {
225        self.execution_status
226    }
227    #[must_use]
228    pub fn liveness(&self) -> AgentLiveness {
229        self.liveness
230    }
231    #[must_use]
232    pub fn source(&self) -> AgentStatusSource {
233        self.source
234    }
235    #[must_use]
236    pub fn requested_model(&self) -> Option<&str> {
237        self.requested_model.as_deref()
238    }
239    #[must_use]
240    pub fn requested_reasoning_effort(&self) -> Option<&str> {
241        self.requested_reasoning_effort.as_deref()
242    }
243    #[must_use]
244    pub fn actual_model(&self) -> Option<&str> {
245        self.actual_model.as_deref()
246    }
247    #[must_use]
248    pub fn actual_reasoning_effort(&self) -> Option<&str> {
249        self.actual_reasoning_effort.as_deref()
250    }
251    #[must_use]
252    pub fn activity(&self) -> &str {
253        &self.activity
254    }
255    #[must_use]
256    pub fn blocker(&self) -> Option<&str> {
257        self.blocker.as_deref()
258    }
259    #[must_use]
260    pub fn dependency(&self) -> Option<&str> {
261        self.dependency.as_deref()
262    }
263    #[must_use]
264    pub fn task_summary(&self) -> &str {
265        &self.task_summary
266    }
267    #[must_use]
268    pub fn evidence_references(&self) -> &[String] {
269        &self.evidence_references
270    }
271    #[must_use]
272    pub const fn usage_kind(&self) -> Option<AgentUsageKind> {
273        self.usage_kind
274    }
275    #[must_use]
276    pub fn usage_value(&self) -> Option<u64> {
277        self.usage_value
278    }
279    #[must_use]
280    pub fn observed_at(&self) -> OffsetDateTime {
281        self.observed_at
282    }
283    #[must_use]
284    pub fn report_sequence(&self) -> u64 {
285        self.report_sequence
286    }
287    #[must_use]
288    pub fn idempotency_key(&self) -> &str {
289        &self.idempotency_key
290    }
291    #[must_use]
292    pub const fn claim_fence(&self) -> &StepClaimFence {
293        &self.claim_fence
294    }
295
296    /// Project liveness from the age of explicit host evidence without
297    /// changing the execution outcome or the persisted observation.
298    #[must_use]
299    pub fn projected_at(&self, now: OffsetDateTime, stale_after: Duration) -> Self {
300        let mut projected = self.clone();
301        if self.source == AgentStatusSource::HostReport
302            && self.liveness == AgentLiveness::Fresh
303            && now - self.observed_at > stale_after
304        {
305            projected.liveness = AgentLiveness::Stale;
306        }
307        projected
308    }
309
310    /// Identity that belongs to the ceremony's logical participant and its
311    /// accepted work, rather than to the host process currently executing it.
312    #[must_use]
313    pub fn has_same_logical_execution_as(&self, other: &Self) -> bool {
314        self.ceremony_id == other.ceremony_id
315            && self.agent_execution_id == other.agent_execution_id
316            && self.logical_worker_id == other.logical_worker_id
317            && self.role_id == other.role_id
318            && self.step_id == other.step_id
319            && self.attempt == other.attempt
320    }
321}
322
323#[allow(clippy::needless_pass_by_value)] // the validated value is retained by the entity
324fn text(value: String, field: &'static str) -> Result<String, DomainError> {
325    let value = value.trim().to_owned();
326    if value.is_empty() {
327        return Err(DomainError::EmptyField { field });
328    }
329    if value.len() > MAX_STATUS_TEXT {
330        return Err(DomainError::FieldTooLong {
331            field,
332            actual: value.len(),
333            max: MAX_STATUS_TEXT,
334        });
335    }
336    if value.chars().any(char::is_control) {
337        return Err(DomainError::InvalidCharacters { field });
338    }
339    Ok(value)
340}
341
342fn optional_text(
343    value: Option<String>,
344    field: &'static str,
345) -> Result<Option<String>, DomainError> {
346    value.map(|value| text(value, field)).transpose()
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn operation_id() -> ExecutionOperationId {
354        ExecutionOperationId::new("1".repeat(64)).unwrap()
355    }
356
357    fn claim_owner_id() -> LeaseOwnerId {
358        LeaseOwnerId::new("host").unwrap()
359    }
360
361    fn claim_fence() -> StepClaimFence {
362        StepClaimFence::new("2".repeat(64)).unwrap()
363    }
364
365    fn ceremony(value: &str) -> CeremonyId {
366        CeremonyId::new(value).unwrap()
367    }
368    fn execution(value: &str) -> CeremonyAgentExecutionId {
369        CeremonyAgentExecutionId::new(value).unwrap()
370    }
371    fn worker(value: &str) -> LogicalWorkerId {
372        LogicalWorkerId::new(value).unwrap()
373    }
374    fn owner(value: &str) -> LeaseOwnerId {
375        LeaseOwnerId::new(value).unwrap()
376    }
377    fn incarnation(value: &str) -> HostAgentIncarnation {
378        HostAgentIncarnation::new(value).unwrap()
379    }
380    fn role(value: &str) -> RoleId {
381        RoleId::new(value).unwrap()
382    }
383    fn step(value: &str) -> StepId {
384        StepId::new(value).unwrap()
385    }
386
387    fn status_with_usage(
388        usage_kind: Option<AgentUsageKind>,
389        usage_value: Option<u64>,
390    ) -> Result<CeremonyAgentStatus, DomainError> {
391        CeremonyAgentStatus::new(
392            ceremony("ceremony"),
393            execution("execution"),
394            operation_id(),
395            claim_owner_id(),
396            worker("worker"),
397            owner("host"),
398            incarnation("inc"),
399            None,
400            None,
401            role("reviewer"),
402            step("step"),
403            1,
404            AgentExecutionStatus::Blocked,
405            AgentLiveness::Fresh,
406            AgentStatusSource::HostReport,
407            None,
408            None,
409            None,
410            None,
411            "waiting for build lock",
412            Some("build lock".into()),
413            None,
414            "bounded summary",
415            vec![],
416            usage_kind,
417            usage_value,
418            OffsetDateTime::UNIX_EPOCH,
419            1,
420            "report-1",
421            claim_fence(),
422        )
423    }
424
425    #[test]
426    fn execution_and_liveness_are_independent() {
427        let status = status_with_usage(Some(AgentUsageKind::Unavailable), None).unwrap();
428        assert_eq!(status.execution_status(), AgentExecutionStatus::Blocked);
429        assert_eq!(status.liveness(), AgentLiveness::Fresh);
430        let aged = status.projected_at(
431            OffsetDateTime::UNIX_EPOCH + Duration::seconds(61),
432            Duration::seconds(60),
433        );
434        assert_eq!(aged.execution_status(), AgentExecutionStatus::Blocked);
435        assert_eq!(aged.liveness(), AgentLiveness::Stale);
436        assert_eq!(aged.source(), AgentStatusSource::HostReport);
437    }
438
439    #[test]
440    fn usage_value_matches_its_provenance() {
441        assert!(status_with_usage(Some(AgentUsageKind::Measured), Some(7)).is_ok());
442        assert!(status_with_usage(Some(AgentUsageKind::Estimated), Some(7)).is_ok());
443        assert!(status_with_usage(Some(AgentUsageKind::Unavailable), None).is_ok());
444        assert!(status_with_usage(Some(AgentUsageKind::Measured), None).is_err());
445        assert!(status_with_usage(Some(AgentUsageKind::Unavailable), Some(7)).is_err());
446        assert!(status_with_usage(None, Some(7)).is_err());
447    }
448
449    #[test]
450    fn handoff_requires_both_previous_identities() {
451        assert!(CeremonyAgentStatus::new(
452            ceremony("c"),
453            execution("e"),
454            operation_id(),
455            claim_owner_id(),
456            worker("w"),
457            owner("h"),
458            incarnation("i"),
459            Some(owner("old")),
460            None,
461            role("r"),
462            step("s"),
463            1,
464            AgentExecutionStatus::Running,
465            AgentLiveness::Fresh,
466            AgentStatusSource::HostReport,
467            None,
468            None,
469            None,
470            None,
471            "work",
472            None,
473            None,
474            "summary",
475            vec![],
476            None,
477            None,
478            OffsetDateTime::UNIX_EPOCH,
479            1,
480            "k",
481            claim_fence(),
482        )
483        .is_err());
484    }
485
486    #[test]
487    fn a_lease_or_unsupported_host_never_fabricates_a_fresh_observation() {
488        for source in [
489            AgentStatusSource::DerivedLease,
490            AgentStatusSource::HostDiscoveryUnsupported,
491        ] {
492            assert!(CeremonyAgentStatus::new(
493                ceremony("c"),
494                execution("e"),
495                operation_id(),
496                claim_owner_id(),
497                worker("participant"),
498                owner("host"),
499                incarnation("inc"),
500                None,
501                None,
502                role("role"),
503                step("step"),
504                1,
505                AgentExecutionStatus::Running,
506                AgentLiveness::Fresh,
507                source,
508                None,
509                None,
510                None,
511                None,
512                "working",
513                None,
514                None,
515                "summary",
516                vec![],
517                None,
518                None,
519                OffsetDateTime::UNIX_EPOCH,
520                1,
521                "key",
522                claim_fence(),
523            )
524            .is_err());
525        }
526        assert!(CeremonyAgentStatus::new(
527            ceremony("c"),
528            execution("e"),
529            operation_id(),
530            claim_owner_id(),
531            worker("participant"),
532            owner("host"),
533            incarnation("inc"),
534            None,
535            None,
536            role("role"),
537            step("step"),
538            1,
539            AgentExecutionStatus::Blocked,
540            AgentLiveness::Stale,
541            AgentStatusSource::DerivedLease,
542            None,
543            None,
544            None,
545            None,
546            "waiting",
547            Some("host report missing".into()),
548            None,
549            "summary",
550            vec![],
551            None,
552            None,
553            OffsetDateTime::UNIX_EPOCH,
554            1,
555            "key",
556            claim_fence(),
557        )
558        .is_ok());
559    }
560}