Skip to main content

starweaver_session/
run_control.rs

1//! Product-neutral durable live-run control admission contracts.
2//!
3//! A control request is first admitted as durable intent under the run's active fencing lease.
4//! Delivery to a process-local runtime happens only after that transaction commits. Runtime
5//! delivery is keyed by [`DurableRunControlIntent::operation_id`], allowing an exact retry after
6//! an ambiguous failure window without injecting a second steering message or interrupt.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::{DurableControlReceipt, ManagedRunTarget, RunAdmissionLease};
13
14/// Durable control effect carried by an admitted intent.
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum DurableRunControlEffect {
18    /// Inject one user steering message at the next runtime control-drain boundary.
19    Steer {
20        /// Model-visible steering text.
21        text: String,
22    },
23    /// Request cooperative interruption of the active runtime.
24    Interrupt {
25        /// Product-neutral, safe reason category or message.
26        #[serde(default, skip_serializing_if = "Option::is_none")]
27        reason: Option<String>,
28    },
29}
30
31impl DurableRunControlEffect {
32    /// Stable operation category used by receipts and persistence indexes.
33    #[must_use]
34    pub const fn operation(&self) -> &'static str {
35        match self {
36            Self::Steer { .. } => "steer",
37            Self::Interrupt { .. } => "interrupt",
38        }
39    }
40}
41
42/// Durable lifecycle of one admitted control effect.
43#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum DurableRunControlStatus {
46    /// Receipt and effect intent committed atomically; no runtime acceptance is yet recorded.
47    Pending,
48    /// The operation-id-aware runtime accepted the effect.
49    Delivered,
50    /// The runtime observed/consumed the effect at a control boundary.
51    Consumed,
52    /// The effect was made irrelevant by terminal state, stale fencing, or recovery policy.
53    Reconciled,
54}
55
56impl DurableRunControlStatus {
57    /// Stable storage/receipt state name.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Pending => "pending",
62            Self::Delivered => "delivered",
63            Self::Consumed => "consumed",
64            Self::Reconciled => "reconciled",
65        }
66    }
67
68    /// Return whether `next` is a valid monotonic transition.
69    #[must_use]
70    pub const fn can_advance_to(self, next: Self) -> bool {
71        self as u8 == next as u8
72            || matches!(
73                (self, next),
74                (Self::Pending, Self::Delivered | Self::Reconciled)
75                    | (Self::Delivered, Self::Consumed | Self::Reconciled)
76                    | (Self::Consumed, Self::Reconciled)
77            )
78    }
79}
80
81/// Request to atomically reserve a receipt and its durable control effect.
82#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
83pub struct AdmitRunControl {
84    /// Active run authority and fencing lease. Stores validate it in the admission transaction.
85    pub lease: RunAdmissionLease,
86    /// Stable product authority identity. Idempotency keys are scoped to this binding.
87    pub authority_binding: String,
88    /// Deterministic operation identity used for runtime deduplication.
89    pub operation_id: String,
90    /// Deterministic durable receipt identity.
91    pub receipt_id: String,
92    /// Authority-scoped idempotency key.
93    pub idempotency_key: String,
94    /// Canonical command fingerprint bound to the key and authority.
95    pub command_fingerprint: String,
96    /// Durable effect payload.
97    pub effect: DurableRunControlEffect,
98    /// Caller-selected admission time retained across exact retries.
99    pub created_at: DateTime<Utc>,
100}
101
102impl AdmitRunControl {
103    /// Convert an admission request into its initial durable intent.
104    #[must_use]
105    pub fn into_intent(self) -> DurableRunControlIntent {
106        let receipt = DurableControlReceipt {
107            receipt_id: self.receipt_id,
108            target: self.lease.target.clone(),
109            operation_id: self.operation_id.clone(),
110            operation: self.effect.operation().to_string(),
111            idempotency_key: self.idempotency_key,
112            command_fingerprint: self.command_fingerprint,
113            fencing_generation: self.lease.fencing_generation,
114            state: DurableRunControlStatus::Pending.as_str().to_string(),
115            created_at: self.created_at,
116        };
117        DurableRunControlIntent {
118            operation_id: self.operation_id,
119            target: self.lease.target,
120            authority_binding: self.authority_binding,
121            admission_id: self.lease.admission_id,
122            host_instance_id: self.lease.host_instance_id,
123            fencing_generation: self.lease.fencing_generation,
124            idempotency_key: receipt.idempotency_key.clone(),
125            command_fingerprint: receipt.command_fingerprint.clone(),
126            receipt,
127            effect: self.effect,
128            status: DurableRunControlStatus::Pending,
129            created_at: self.created_at,
130            delivered_at: None,
131            consumed_at: None,
132            reconciled_at: None,
133        }
134    }
135}
136
137/// Receipt plus durable steering inbox entry or interrupt intent.
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
139pub struct DurableRunControlIntent {
140    /// Runtime deduplication identity.
141    pub operation_id: String,
142    /// Composite run target.
143    pub target: ManagedRunTarget,
144    /// Product authority binding.
145    pub authority_binding: String,
146    /// Admission identity that owned the run when the effect was accepted.
147    pub admission_id: String,
148    /// Host authority that owned the admission.
149    pub host_instance_id: String,
150    /// Monotonic fencing generation.
151    pub fencing_generation: u64,
152    /// Authority-scoped idempotency key.
153    pub idempotency_key: String,
154    /// Canonical command fingerprint.
155    pub command_fingerprint: String,
156    /// Durable public receipt reserved in the same transaction.
157    pub receipt: DurableControlReceipt,
158    /// Steering inbox entry or interrupt intent.
159    pub effect: DurableRunControlEffect,
160    /// Current durable effect state.
161    pub status: DurableRunControlStatus,
162    /// Admission time.
163    pub created_at: DateTime<Utc>,
164    /// First durable runtime-delivery acknowledgement.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub delivered_at: Option<DateTime<Utc>>,
167    /// First durable runtime-consumption acknowledgement.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub consumed_at: Option<DateTime<Utc>>,
170    /// Recovery reconciliation time.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub reconciled_at: Option<DateTime<Utc>>,
173}
174
175impl starweaver_core::VersionedRecord for DurableRunControlIntent {
176    const SCHEMA: &'static str = "starweaver.session.durable_run_control_intent";
177}
178
179impl DurableRunControlIntent {
180    /// Return whether this intent has exactly the admission identity supplied by a retry.
181    #[must_use]
182    pub fn matches_admission(&self, request: &AdmitRunControl) -> bool {
183        self.target == request.lease.target
184            && self.authority_binding == request.authority_binding
185            && self.admission_id == request.lease.admission_id
186            && self.host_instance_id == request.lease.host_instance_id
187            && self.fencing_generation == request.lease.fencing_generation
188            && self.operation_id == request.operation_id
189            && self.receipt.receipt_id == request.receipt_id
190            && self.idempotency_key == request.idempotency_key
191            && self.command_fingerprint == request.command_fingerprint
192            && self.effect == request.effect
193    }
194
195    /// Advance the state and synchronize the compatibility receipt projection.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error for a non-monotonic state transition.
200    pub fn advance(
201        &mut self,
202        next: DurableRunControlStatus,
203        occurred_at: DateTime<Utc>,
204    ) -> Result<(), &'static str> {
205        if !self.status.can_advance_to(next) {
206            return Err("invalid durable run control state transition");
207        }
208        if self.status == next {
209            return Ok(());
210        }
211        self.status = next;
212        self.receipt.state = next.as_str().to_string();
213        match next {
214            DurableRunControlStatus::Pending => {}
215            DurableRunControlStatus::Delivered => self.delivered_at = Some(occurred_at),
216            DurableRunControlStatus::Consumed => {
217                self.delivered_at.get_or_insert(occurred_at);
218                self.consumed_at = Some(occurred_at);
219            }
220            DurableRunControlStatus::Reconciled => self.reconciled_at = Some(occurred_at),
221        }
222        Ok(())
223    }
224}
225
226/// Derive a deterministic operation id from all authority/idempotency identity components.
227#[must_use]
228pub fn deterministic_run_control_operation_id(
229    operation: &str,
230    authority_binding: &str,
231    target: &ManagedRunTarget,
232    idempotency_key: &str,
233    command_fingerprint: &str,
234) -> String {
235    let mut digest = Sha256::new();
236    for component in [
237        "starweaver.session.run_control.operation.v1",
238        operation,
239        authority_binding,
240        target.namespace_id.as_str(),
241        target.session_id.as_str(),
242        target.run_id.as_str(),
243        idempotency_key,
244        command_fingerprint,
245    ] {
246        digest.update(component.len().to_be_bytes());
247        digest.update(component.as_bytes());
248    }
249    format!("run_control_{:x}", digest.finalize())
250}
251
252/// Derive a deterministic receipt id from the operation identity.
253#[must_use]
254pub fn deterministic_run_control_receipt_id(operation_id: &str) -> String {
255    let mut digest = Sha256::new();
256    digest.update(b"starweaver.session.run_control.receipt.v1\0");
257    digest.update(operation_id.as_bytes());
258    format!("control_{:x}", digest.finalize())
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use starweaver_core::{RunId, SessionId};
265
266    #[test]
267    fn operation_identity_binds_authority_target_key_and_fingerprint() {
268        let target = ManagedRunTarget::new(
269            "local",
270            SessionId::from_string("session-a"),
271            RunId::from_string("run-a"),
272        );
273        let first = deterministic_run_control_operation_id(
274            "steer",
275            "authority-a",
276            &target,
277            "key-a",
278            "sha256:a",
279        );
280        assert_eq!(
281            first,
282            deterministic_run_control_operation_id(
283                "steer",
284                "authority-a",
285                &target,
286                "key-a",
287                "sha256:a"
288            )
289        );
290        assert_ne!(
291            first,
292            deterministic_run_control_operation_id(
293                "steer",
294                "authority-b",
295                &target,
296                "key-a",
297                "sha256:a"
298            )
299        );
300        assert_ne!(
301            first,
302            deterministic_run_control_operation_id(
303                "interrupt",
304                "authority-a",
305                &target,
306                "key-a",
307                "sha256:a"
308            )
309        );
310    }
311
312    #[test]
313    fn control_states_are_monotonic() {
314        assert!(
315            DurableRunControlStatus::Pending.can_advance_to(DurableRunControlStatus::Delivered)
316        );
317        assert!(
318            DurableRunControlStatus::Delivered.can_advance_to(DurableRunControlStatus::Consumed)
319        );
320        assert!(
321            DurableRunControlStatus::Consumed.can_advance_to(DurableRunControlStatus::Reconciled)
322        );
323        assert!(
324            !DurableRunControlStatus::Consumed.can_advance_to(DurableRunControlStatus::Pending)
325        );
326    }
327}