Skip to main content

appcore_contracts/
runtime.rs

1//! Runtime-produced manifest contract.
2
3use crate::identifiers::{is_sensitive_key, validate_text};
4use crate::{
5    BuildId, CapabilityId, ContractError, ContractResult, CoreId, CoreProfile, FeatureId, NodeId,
6    ProviderId, RuntimeMode,
7};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10
11/// Schema version written by [`RuntimeManifestV1`].
12pub const RUNTIME_MANIFEST_VERSION: u16 = 1;
13
14/// Coarse health state produced by a runtime.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RuntimeHealthStatus {
18    /// Runtime is operating normally.
19    Healthy,
20    /// Runtime remains available with reduced guarantees.
21    Degraded,
22    /// Runtime is unable to serve its declared contract.
23    Unhealthy,
24}
25
26/// Observable health snapshot without application data or secrets.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct RuntimeHealth {
29    status: RuntimeHealthStatus,
30    checked_at_ms: u64,
31    details: BTreeMap<String, String>,
32}
33
34impl RuntimeHealth {
35    /// Creates an empty runtime health snapshot.
36    pub fn new(status: RuntimeHealthStatus, checked_at_ms: u64) -> Self {
37        Self {
38            status,
39            checked_at_ms,
40            details: BTreeMap::new(),
41        }
42    }
43
44    /// Adds a non-sensitive diagnostic detail.
45    pub fn with_detail(
46        mut self,
47        key: impl Into<String>,
48        value: impl Into<String>,
49    ) -> ContractResult<Self> {
50        let key = key.into();
51        let value = value.into();
52        validate_health_detail(&key, &value)?;
53        self.details.insert(key, value);
54        Ok(self)
55    }
56
57    /// Returns the health status.
58    pub fn status(&self) -> RuntimeHealthStatus {
59        self.status
60    }
61
62    /// Returns the snapshot timestamp in Unix milliseconds.
63    pub fn checked_at_ms(&self) -> u64 {
64        self.checked_at_ms
65    }
66
67    /// Returns non-sensitive health details.
68    pub fn details(&self) -> &BTreeMap<String, String> {
69        &self.details
70    }
71
72    fn validate(&self) -> ContractResult<()> {
73        for (key, value) in &self.details {
74            validate_health_detail(key, value)?;
75        }
76        Ok(())
77    }
78}
79
80/// Operational state relevant to routing and scheduling decisions.
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum RuntimeOperationalMode {
84    /// Runtime is initializing local infrastructure.
85    Starting,
86    /// Runtime is discovering cluster peers.
87    Discovering,
88    /// Runtime is synchronizing state.
89    Syncing,
90    /// Runtime accepts read-only work.
91    ReadOnly,
92    /// Runtime accepts reads and writes.
93    #[default]
94    ReadWrite,
95    /// Runtime remains partially available.
96    Degraded,
97    /// Runtime is detached from required cluster coordination.
98    Isolated,
99}
100
101impl RuntimeOperationalMode {
102    /// Reports whether local queries are allowed.
103    pub fn allows_local_queries(self) -> bool {
104        matches!(
105            self,
106            Self::ReadOnly | Self::ReadWrite | Self::Degraded | Self::Isolated
107        )
108    }
109
110    /// Reports whether writes are allowed.
111    pub fn allows_writes(self) -> bool {
112        matches!(self, Self::ReadWrite)
113    }
114
115    /// Returns the stable serialized label.
116    pub fn as_str(self) -> &'static str {
117        match self {
118            Self::Starting => "starting",
119            Self::Discovering => "discovering",
120            Self::Syncing => "syncing",
121            Self::ReadOnly => "read_only",
122            Self::ReadWrite => "read_write",
123            Self::Degraded => "degraded",
124            Self::Isolated => "isolated",
125        }
126    }
127}
128
129impl TryFrom<&str> for RuntimeOperationalMode {
130    type Error = ContractError;
131
132    fn try_from(value: &str) -> Result<Self, Self::Error> {
133        match value {
134            "starting" => Ok(Self::Starting),
135            "discovering" => Ok(Self::Discovering),
136            "syncing" => Ok(Self::Syncing),
137            "read_only" => Ok(Self::ReadOnly),
138            "read_write" => Ok(Self::ReadWrite),
139            "readonly" | "readwrite" => Err(ContractError::InvalidValue {
140                field: "operational_mode",
141                reason: "NO MORE SUPPORTED PLEASE UPDATE",
142            }),
143            "degraded" => Ok(Self::Degraded),
144            "isolated" => Ok(Self::Isolated),
145            _ => Err(ContractError::InvalidValue {
146                field: "operational_mode",
147                reason: "unsupported operational mode",
148            }),
149        }
150    }
151}
152
153/// Runtime-owned description of a running host.
154///
155/// Application identity and application metadata are deliberately absent.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(try_from = "RuntimeManifestData")]
158pub struct RuntimeManifestV1 {
159    manifest_version: u16,
160    runtime_version: String,
161    protocol_version: String,
162    build_id: BuildId,
163    features: BTreeSet<FeatureId>,
164    node_id: NodeId,
165    core_id: CoreId,
166    mode: RuntimeMode,
167    platform: String,
168    architecture: String,
169    storage_backend: ProviderId,
170    health: RuntimeHealth,
171    operational_mode: RuntimeOperationalMode,
172    loaded_capabilities: BTreeSet<CapabilityId>,
173    core_profile: CoreProfile,
174}
175
176#[derive(Deserialize)]
177struct RuntimeManifestData {
178    manifest_version: u16,
179    runtime_version: String,
180    protocol_version: String,
181    build_id: BuildId,
182    features: BTreeSet<FeatureId>,
183    node_id: NodeId,
184    core_id: CoreId,
185    mode: RuntimeMode,
186    platform: String,
187    architecture: String,
188    storage_backend: ProviderId,
189    health: RuntimeHealth,
190    operational_mode: RuntimeOperationalMode,
191    loaded_capabilities: BTreeSet<CapabilityId>,
192    core_profile: CoreProfile,
193}
194
195impl RuntimeManifestV1 {
196    /// Creates a runtime-produced manifest from runtime and node facts.
197    #[allow(clippy::too_many_arguments)]
198    pub fn new(
199        runtime_version: impl Into<String>,
200        protocol_version: impl Into<String>,
201        build_id: BuildId,
202        node_id: NodeId,
203        core_id: CoreId,
204        mode: RuntimeMode,
205        platform: impl Into<String>,
206        architecture: impl Into<String>,
207        storage_backend: ProviderId,
208        health: RuntimeHealth,
209        core_profile: CoreProfile,
210    ) -> ContractResult<Self> {
211        let manifest = Self {
212            manifest_version: RUNTIME_MANIFEST_VERSION,
213            runtime_version: runtime_version.into(),
214            protocol_version: protocol_version.into(),
215            build_id,
216            features: BTreeSet::new(),
217            node_id,
218            core_id,
219            mode,
220            platform: platform.into(),
221            architecture: architecture.into(),
222            storage_backend,
223            health,
224            operational_mode: RuntimeOperationalMode::Starting,
225            loaded_capabilities: BTreeSet::new(),
226            core_profile,
227        };
228        manifest.validate()?;
229        Ok(manifest)
230    }
231
232    /// Adds a runtime feature.
233    pub fn with_feature(mut self, feature: FeatureId) -> Self {
234        self.features.insert(feature);
235        self
236    }
237
238    /// Adds a capability loaded by the current host.
239    pub fn with_loaded_capability(mut self, capability: CapabilityId) -> ContractResult<Self> {
240        if !self.core_profile.capabilities().contains(&capability) {
241            return Err(ContractError::InvalidValue {
242                field: "loaded_capabilities",
243                reason: "capability is not declared by the core profile",
244            });
245        }
246        self.loaded_capabilities.insert(capability);
247        Ok(self)
248    }
249
250    /// Replaces observable health.
251    pub fn with_health(mut self, health: RuntimeHealth) -> ContractResult<Self> {
252        health.validate()?;
253        self.health = health;
254        Ok(self)
255    }
256
257    /// Replaces the current operational mode.
258    pub fn with_operational_mode(mut self, mode: RuntimeOperationalMode) -> Self {
259        self.operational_mode = mode;
260        self
261    }
262
263    /// Returns the manifest schema version.
264    pub fn manifest_version(&self) -> u16 {
265        self.manifest_version
266    }
267
268    /// Returns the AppCore runtime version.
269    pub fn runtime_version(&self) -> &str {
270        &self.runtime_version
271    }
272
273    /// Returns the distributed protocol version.
274    pub fn protocol_version(&self) -> &str {
275        &self.protocol_version
276    }
277
278    /// Returns the immutable runtime build identity.
279    pub fn build_id(&self) -> &BuildId {
280        &self.build_id
281    }
282
283    /// Returns enabled runtime features.
284    pub fn features(&self) -> &BTreeSet<FeatureId> {
285        &self.features
286    }
287
288    /// Returns the host node identity.
289    pub fn node_id(&self) -> &NodeId {
290        &self.node_id
291    }
292
293    /// Returns the executable core identity.
294    pub fn core_id(&self) -> &CoreId {
295        &self.core_id
296    }
297
298    /// Returns the explicit standalone or cluster mode.
299    pub fn mode(&self) -> RuntimeMode {
300        self.mode
301    }
302
303    /// Returns the host platform.
304    pub fn platform(&self) -> &str {
305        &self.platform
306    }
307
308    /// Returns the host architecture.
309    pub fn architecture(&self) -> &str {
310        &self.architecture
311    }
312
313    /// Returns the active storage backend identity.
314    pub fn storage_backend(&self) -> &ProviderId {
315        &self.storage_backend
316    }
317
318    /// Returns observable runtime health.
319    pub fn health(&self) -> &RuntimeHealth {
320        &self.health
321    }
322
323    /// Returns the current operational mode.
324    pub fn operational_mode(&self) -> RuntimeOperationalMode {
325        self.operational_mode
326    }
327
328    /// Returns capabilities loaded by this runtime.
329    pub fn loaded_capabilities(&self) -> &BTreeSet<CapabilityId> {
330        &self.loaded_capabilities
331    }
332
333    /// Returns the scheduler-facing core profile.
334    pub fn core_profile(&self) -> &CoreProfile {
335        &self.core_profile
336    }
337
338    /// Validates runtime facts and cross-field capability declarations.
339    pub fn validate(&self) -> ContractResult<()> {
340        if self.manifest_version != RUNTIME_MANIFEST_VERSION {
341            return Err(ContractError::InvalidValue {
342                field: "manifest_version",
343                reason: "unsupported runtime manifest version",
344            });
345        }
346        validate_text("runtime_version", &self.runtime_version, 64)?;
347        validate_text("protocol_version", &self.protocol_version, 64)?;
348        validate_text("platform", &self.platform, 128)?;
349        validate_text("architecture", &self.architecture, 128)?;
350        self.health.validate()?;
351        self.core_profile.validate()?;
352        if !self
353            .loaded_capabilities
354            .is_subset(self.core_profile.capabilities())
355        {
356            return Err(ContractError::InvalidValue {
357                field: "loaded_capabilities",
358                reason: "loaded capabilities must be declared by the core profile",
359            });
360        }
361        Ok(())
362    }
363}
364
365impl TryFrom<RuntimeManifestData> for RuntimeManifestV1 {
366    type Error = ContractError;
367
368    fn try_from(data: RuntimeManifestData) -> Result<Self, Self::Error> {
369        let manifest = Self {
370            manifest_version: data.manifest_version,
371            runtime_version: data.runtime_version,
372            protocol_version: data.protocol_version,
373            build_id: data.build_id,
374            features: data.features,
375            node_id: data.node_id,
376            core_id: data.core_id,
377            mode: data.mode,
378            platform: data.platform,
379            architecture: data.architecture,
380            storage_backend: data.storage_backend,
381            health: data.health,
382            operational_mode: data.operational_mode,
383            loaded_capabilities: data.loaded_capabilities,
384            core_profile: data.core_profile,
385        };
386        manifest.validate()?;
387        Ok(manifest)
388    }
389}
390
391fn validate_health_detail(key: &str, value: &str) -> ContractResult<()> {
392    validate_text("health.detail.key", key, 128)?;
393    validate_text("health.detail.value", value, 2_048)?;
394    if is_sensitive_key(key) {
395        return Err(ContractError::SecretValue {
396            field: format!("health.details.{key}"),
397        });
398    }
399    Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::{
406        CoreRole, LeadershipMode, LeadershipRequirement, ResourceProfile, SchedulingProfile,
407        ServiceId, WorkloadClass,
408    };
409
410    fn profile() -> CoreProfile {
411        let service = ServiceId::new("document.extract").unwrap();
412        CoreProfile::new(
413            CoreRole::Compute,
414            service.clone(),
415            [CapabilityId::new("document.extract").unwrap()],
416            LeadershipRequirement::new(service, LeadershipMode::Required, 30_000).unwrap(),
417            ResourceProfile::new(Some(8), Some(16_000_000_000), 1),
418            SchedulingProfile::new(10, 5, 4, WorkloadClass::Compute).unwrap(),
419        )
420        .unwrap()
421    }
422
423    fn manifest() -> RuntimeManifestV1 {
424        RuntimeManifestV1::new(
425            "0.6.1",
426            "1",
427            BuildId::new("build-123").unwrap(),
428            NodeId::new("node-a").unwrap(),
429            CoreId::new("core-a").unwrap(),
430            RuntimeMode::Cluster,
431            "linux",
432            "aarch64",
433            ProviderId::new("file").unwrap(),
434            RuntimeHealth::new(RuntimeHealthStatus::Healthy, 100),
435            profile(),
436        )
437        .unwrap()
438        .with_loaded_capability(CapabilityId::new("document.extract").unwrap())
439        .unwrap()
440    }
441
442    #[test]
443    fn runtime_manifest_contains_runtime_facts_only() {
444        let encoded = serde_json::to_value(manifest()).unwrap();
445        assert!(encoded.get("application_id").is_none());
446        assert!(encoded.get("vendor").is_none());
447        assert_eq!(encoded["mode"], "cluster");
448    }
449
450    #[test]
451    fn runtime_manifest_round_trip_revalidates_capabilities() {
452        let manifest = manifest();
453        let encoded = serde_json::to_string(&manifest).unwrap();
454        let decoded: RuntimeManifestV1 = serde_json::from_str(&encoded).unwrap();
455        assert_eq!(manifest, decoded);
456    }
457
458    #[test]
459    fn runtime_manifest_matches_v1_fixture() {
460        let expected: serde_json::Value =
461            serde_json::from_str(include_str!("fixtures/runtime-manifest-v1.json")).unwrap();
462        assert_eq!(serde_json::to_value(manifest()).unwrap(), expected);
463        let decoded: RuntimeManifestV1 = serde_json::from_value(expected).unwrap();
464        assert_eq!(decoded, manifest());
465    }
466
467    #[test]
468    fn health_rejects_sensitive_details() {
469        assert!(RuntimeHealth::new(RuntimeHealthStatus::Healthy, 0)
470            .with_detail("access_token", "raw")
471            .is_err());
472    }
473}