Skip to main content

telltale_types/
reconfiguration.rs

1//! Reconfiguration and placement types for protocol role migration.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7/// Canonical placement kind used by reconfiguration and recovery artifacts.
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
9pub enum PlacementKind {
10    /// In-process placement.
11    Local,
12    /// Remote endpoint placement.
13    Remote,
14    /// Colocated with another role.
15    Colocated,
16}
17
18/// Canonical placement observation for one active role.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
20pub struct PlacementObservation {
21    /// Stable role name.
22    pub role: String,
23    /// Canonical placement kind.
24    pub kind: PlacementKind,
25    /// Optional remote endpoint for remote roles.
26    pub endpoint: Option<String>,
27    /// Optional region hint.
28    pub region: Option<String>,
29    /// Peer role for colocated placement.
30    pub colocated_with: Option<String>,
31}
32
33impl PlacementObservation {
34    /// Construct a local placement observation.
35    #[must_use]
36    pub fn local(role: impl Into<String>) -> Self {
37        Self {
38            role: role.into(),
39            kind: PlacementKind::Local,
40            endpoint: None,
41            region: None,
42            colocated_with: None,
43        }
44    }
45
46    /// Construct a remote placement observation.
47    #[must_use]
48    pub fn remote(role: impl Into<String>, endpoint: impl Into<String>) -> Self {
49        Self {
50            role: role.into(),
51            kind: PlacementKind::Remote,
52            endpoint: Some(endpoint.into()),
53            region: None,
54            colocated_with: None,
55        }
56    }
57
58    /// Construct a colocated placement observation.
59    #[must_use]
60    pub fn colocated(role: impl Into<String>, peer: impl Into<String>) -> Self {
61        Self {
62            role: role.into(),
63            kind: PlacementKind::Colocated,
64            endpoint: None,
65            region: None,
66            colocated_with: Some(peer.into()),
67        }
68    }
69
70    /// Attach a canonical region hint.
71    #[must_use]
72    pub fn with_region(mut self, region: impl Into<String>) -> Self {
73        self.region = Some(region.into());
74        self
75    }
76}
77
78/// Derived transport boundary kind between two active roles.
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
80pub enum TransportBoundaryKind {
81    /// Both roles are in the same process without an explicit colocated hop.
82    InProcess,
83    /// Roles share a node through explicit colocated placement.
84    SharedMemory,
85    /// At least one side is remote.
86    Network,
87}
88
89/// Canonical transport-observable boundary derived from placement observations.
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
91pub struct TransportBoundaryObservation {
92    /// Stable lower-sorted role name.
93    pub from_role: String,
94    /// Stable higher-sorted role name.
95    pub to_role: String,
96    /// Derived boundary kind.
97    pub boundary: TransportBoundaryKind,
98    /// Whether the two roles resolve to different explicit regions.
99    pub cross_region: bool,
100}
101
102/// Canonical phase for a transition or runtime-upgrade artifact.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum TransitionArtifactPhase {
106    /// Transition is staged but not yet admitted.
107    Staged,
108    /// Transition passed admission and compatibility checks.
109    Admitted,
110    /// Transition committed a new active runtime cutover.
111    CommittedCutover,
112    /// Transition was rolled back after staging/admission.
113    RolledBack,
114    /// Transition failed before commit.
115    Failed,
116}
117
118/// Pending-effect treatment required for a specialized runtime upgrade.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum PendingEffectTreatment {
122    /// Pending effects are carried across the cutover.
123    PreservePending,
124    /// Blocked/invalidated effects must be made explicit before cutover.
125    InvalidateBlocked,
126}
127
128/// Canonical publication continuity policy required for a runtime upgrade.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum CanonicalPublicationContinuity {
132    /// Existing canonical publications/handles must stay valid across the cutover.
133    PreserveCanonicalTruth,
134    /// Canonical publications may be invalidated and re-issued.
135    ReissueCanonicalTruth,
136}
137
138/// Execution-profile constraint required for a runtime upgrade.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum RuntimeUpgradeExecutionConstraint {
142    /// Preserve the admitted proof-carrying bundle profile.
143    PreserveBundleProfile,
144    /// Requires theorem-pack/runtime support for mixed determinism profiles.
145    MixedDeterminismAllowed,
146}
147
148/// Canonical compatibility contract for one specialized runtime upgrade.
149#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
150pub struct RuntimeUpgradeCompatibility {
151    /// Execution-profile constraint for the upgrade.
152    pub execution_constraint: RuntimeUpgradeExecutionConstraint,
153    /// Whether ownership continuity must be preserved across the cutover.
154    pub ownership_continuity_required: bool,
155    /// Pending-effect treatment policy.
156    pub pending_effect_treatment: PendingEffectTreatment,
157    /// Canonical publication continuity policy.
158    pub canonical_publication_continuity: CanonicalPublicationContinuity,
159}
160
161/// Canonical serialized artifact for one runtime-upgrade transition phase.
162#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
163pub struct RuntimeUpgradeArtifact {
164    /// Stable upgrade identifier.
165    pub upgrade_id: String,
166    /// Phase represented by this artifact.
167    pub phase: TransitionArtifactPhase,
168    /// Canonical sorted members before this phase.
169    pub previous_members: Vec<String>,
170    /// Canonical sorted members after this phase.
171    pub next_members: Vec<String>,
172    /// Compatibility contract admitted for this upgrade.
173    pub compatibility: RuntimeUpgradeCompatibility,
174    /// Canonical publication ids carried into the new runtime.
175    pub carried_publication_ids: Vec<String>,
176    /// Canonical publication ids invalidated by the cutover.
177    pub invalidated_publication_ids: Vec<String>,
178    /// Stable obligation ids carried into the new runtime.
179    pub carried_obligation_ids: Vec<String>,
180    /// Stable obligation ids invalidated by the cutover.
181    pub invalidated_obligation_ids: Vec<String>,
182    /// Optional failure/rollback reason.
183    pub reason: Option<String>,
184}
185
186#[derive(Debug, Clone)]
187struct ResolvedPlacement {
188    node_key: String,
189    region: Option<String>,
190    uses_colocation: bool,
191    remote: bool,
192}
193
194fn normalize_placement_observations(
195    observations: &[PlacementObservation],
196) -> Result<Vec<PlacementObservation>, String> {
197    let mut normalized = observations.to_vec();
198    normalized.sort_by(|left, right| left.role.cmp(&right.role));
199
200    let mut seen_roles = BTreeSet::new();
201    for observation in &normalized {
202        if !seen_roles.insert(observation.role.clone()) {
203            return Err(format!(
204                "duplicate placement observation for role {}",
205                observation.role
206            ));
207        }
208        match observation.kind {
209            PlacementKind::Local => {
210                if observation.endpoint.is_some() {
211                    return Err(format!(
212                        "local role {} must not carry a remote endpoint",
213                        observation.role
214                    ));
215                }
216                if observation.colocated_with.is_some() {
217                    return Err(format!(
218                        "local role {} must not carry colocated_with metadata",
219                        observation.role
220                    ));
221                }
222            }
223            PlacementKind::Remote => {
224                if observation.endpoint.is_none() {
225                    return Err(format!(
226                        "remote role {} must carry an endpoint",
227                        observation.role
228                    ));
229                }
230                if observation.colocated_with.is_some() {
231                    return Err(format!(
232                        "remote role {} must not carry colocated_with metadata",
233                        observation.role
234                    ));
235                }
236            }
237            PlacementKind::Colocated => {
238                let Some(peer) = observation.colocated_with.as_ref() else {
239                    return Err(format!(
240                        "colocated role {} must name its colocated peer",
241                        observation.role
242                    ));
243                };
244                if peer == &observation.role {
245                    return Err(format!(
246                        "role {} may not be colocated with itself",
247                        observation.role
248                    ));
249                }
250                if observation.endpoint.is_some() {
251                    return Err(format!(
252                        "colocated role {} must not carry a direct endpoint",
253                        observation.role
254                    ));
255                }
256            }
257        }
258    }
259
260    Ok(normalized)
261}
262
263fn resolve_placement(
264    role: &str,
265    placements: &BTreeMap<String, PlacementObservation>,
266    visiting: &mut BTreeSet<String>,
267) -> Result<ResolvedPlacement, String> {
268    if !visiting.insert(role.to_string()) {
269        return Err(format!("cyclic colocated placement involving role {role}"));
270    }
271
272    let resolved = match placements.get(role) {
273        Some(PlacementObservation {
274            kind: PlacementKind::Local,
275            region,
276            ..
277        }) => Ok(ResolvedPlacement {
278            node_key: "local".to_string(),
279            region: region.clone(),
280            uses_colocation: false,
281            remote: false,
282        }),
283        Some(PlacementObservation {
284            kind: PlacementKind::Remote,
285            endpoint,
286            region,
287            ..
288        }) => Ok(ResolvedPlacement {
289            node_key: format!("remote:{}", endpoint.clone().unwrap_or_default()),
290            region: region.clone(),
291            uses_colocation: false,
292            remote: true,
293        }),
294        Some(PlacementObservation {
295            kind: PlacementKind::Colocated,
296            role,
297            colocated_with,
298            region,
299            ..
300        }) => {
301            let peer = colocated_with
302                .as_ref()
303                .expect("normalized colocated observation should name its peer");
304            let inherited = resolve_placement(peer, placements, visiting)?;
305            if let (Some(explicit), Some(inherited_region)) =
306                (region.as_ref(), inherited.region.as_ref())
307            {
308                if explicit != inherited_region {
309                    return Err(format!(
310                        "role {role} declares region {explicit} but colocated peer resolves to {inherited_region}"
311                    ));
312                }
313            }
314            Ok(ResolvedPlacement {
315                node_key: inherited.node_key,
316                region: region.clone().or(inherited.region),
317                uses_colocation: true,
318                remote: inherited.remote,
319            })
320        }
321        None => Err(format!("placement observation is missing role {role}")),
322    };
323
324    visiting.remove(role);
325    resolved
326}
327
328/// Canonicalize and validate placement observations.
329///
330/// # Errors
331///
332/// Returns a descriptive error when the observation set is internally inconsistent.
333pub fn canonicalize_placement_observations(
334    observations: &[PlacementObservation],
335) -> Result<Vec<PlacementObservation>, String> {
336    let normalized = normalize_placement_observations(observations)?;
337    let placements = normalized
338        .iter()
339        .cloned()
340        .map(|observation| (observation.role.clone(), observation))
341        .collect::<BTreeMap<_, _>>();
342    for role in placements.keys() {
343        resolve_placement(role, &placements, &mut BTreeSet::new())?;
344    }
345    Ok(normalized)
346}
347
348/// Derive canonical transport-observable boundaries for a placement set.
349///
350/// # Errors
351///
352/// Returns a descriptive error when the observation set is internally inconsistent.
353pub fn canonical_transport_boundaries(
354    observations: &[PlacementObservation],
355) -> Result<Vec<TransportBoundaryObservation>, String> {
356    let normalized = canonicalize_placement_observations(observations)?;
357    let placements = normalized
358        .iter()
359        .cloned()
360        .map(|observation| (observation.role.clone(), observation))
361        .collect::<BTreeMap<_, _>>();
362    let mut resolved = BTreeMap::new();
363    for role in placements.keys() {
364        resolved.insert(
365            role.clone(),
366            resolve_placement(role, &placements, &mut BTreeSet::new())?,
367        );
368    }
369
370    let roles = normalized
371        .iter()
372        .map(|observation| observation.role.clone())
373        .collect::<Vec<_>>();
374    let mut boundaries = Vec::new();
375    for (index, left_role) in roles.iter().enumerate() {
376        for right_role in roles.iter().skip(index + 1) {
377            let left_resolved = resolved
378                .get(left_role)
379                .expect("resolved placements should exist for every role");
380            let right_resolved = resolved
381                .get(right_role)
382                .expect("resolved placements should exist for every role");
383            let boundary = if left_resolved.remote || right_resolved.remote {
384                TransportBoundaryKind::Network
385            } else if left_resolved.uses_colocation || right_resolved.uses_colocation {
386                TransportBoundaryKind::SharedMemory
387            } else {
388                TransportBoundaryKind::InProcess
389            };
390            let cross_region = match (&left_resolved.region, &right_resolved.region) {
391                (Some(left), Some(right)) => left != right,
392                _ => false,
393            };
394            boundaries.push(TransportBoundaryObservation {
395                from_role: left_role.clone(),
396                to_role: right_role.clone(),
397                boundary,
398                cross_region,
399            });
400        }
401    }
402    Ok(boundaries)
403}
404
405#[cfg(test)]
406mod tests {
407    use super::{canonical_transport_boundaries, PlacementObservation, TransportBoundaryKind};
408
409    #[test]
410    fn remote_and_colocated_boundaries_are_canonical() {
411        let boundaries = canonical_transport_boundaries(&[
412            PlacementObservation::local("Alice").with_region("eu_central_1"),
413            PlacementObservation::remote("Bob", "127.0.0.1:19801").with_region("eu_west_1"),
414            PlacementObservation::colocated("Carol", "Alice").with_region("eu_central_1"),
415        ])
416        .expect("valid placement observations");
417
418        assert_eq!(
419            boundaries,
420            vec![
421                super::TransportBoundaryObservation {
422                    from_role: "Alice".to_string(),
423                    to_role: "Bob".to_string(),
424                    boundary: TransportBoundaryKind::Network,
425                    cross_region: true,
426                },
427                super::TransportBoundaryObservation {
428                    from_role: "Alice".to_string(),
429                    to_role: "Carol".to_string(),
430                    boundary: TransportBoundaryKind::SharedMemory,
431                    cross_region: false,
432                },
433                super::TransportBoundaryObservation {
434                    from_role: "Bob".to_string(),
435                    to_role: "Carol".to_string(),
436                    boundary: TransportBoundaryKind::Network,
437                    cross_region: true,
438                },
439            ]
440        );
441    }
442
443    #[test]
444    fn conflicting_colocated_regions_reject() {
445        let error = canonical_transport_boundaries(&[
446            PlacementObservation::local("Alice").with_region("eu_central_1"),
447            PlacementObservation::colocated("Bob", "Alice").with_region("us_east_1"),
448        ])
449        .expect_err("conflicting colocated regions must reject");
450
451        assert!(
452            error.contains("declares region"),
453            "expected explicit colocated-region conflict, got {error}"
454        );
455    }
456}