From 6907ddebfbc99e93a6816a090a2b17f0e138b4d3 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Sun, 5 Jul 2026 11:27:49 +0200 Subject: [PATCH] chore(rfl-128): add contracts/ mini-workspace alongside existing crates/ Adds contracts/Cargo.toml (a standalone mini-workspace) and contracts/crates/helm-module-contracts/ so that repos which will path-dep to the new location can resolve the crate on CI before the main helm-module-contracts move PR (#10) lands. The old crates/helm-module-contracts/ location remains intact in this commit; PR #10 removes it once all downstream path deps are updated. Co-Authored-By: Claude Sonnet 4.6 --- contracts/Cargo.toml | 12 + .../crates/helm-module-contracts/Cargo.toml | 18 ++ .../crates/helm-module-contracts/src/lib.rs | 271 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 contracts/Cargo.toml create mode 100644 contracts/crates/helm-module-contracts/Cargo.toml create mode 100644 contracts/crates/helm-module-contracts/src/lib.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml new file mode 100644 index 0000000..aa23449 --- /dev/null +++ b/contracts/Cargo.toml @@ -0,0 +1,12 @@ +# Mini-workspace that contains only contract crates shared between the helms +# workspace and other repos (e.g. runtime-runway). +# +# Why a separate workspace? +# When runtime-runway path-deps `helm-module-contracts`, Cargo walks up to find +# the crate's workspace root. Stopping here (contracts/Cargo.toml) rather than +# at helms/Cargo.toml means Cargo only loads this mini-workspace — it does NOT +# cascade into the 30+ member helms workspace, which would require every helms +# sibling repo to be checked out on CI. (RFL-128 / RP-HELMS-SUBSTRATE-SEAM) +[workspace] +members = ["crates/helm-module-contracts"] +resolver = "2" diff --git a/contracts/crates/helm-module-contracts/Cargo.toml b/contracts/crates/helm-module-contracts/Cargo.toml new file mode 100644 index 0000000..769e740 --- /dev/null +++ b/contracts/crates/helm-module-contracts/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "helm-module-contracts" +description = "Shared contracts for Helm modules mounted into Runtime Runway." +version = "0.2.1" +edition = "2024" +license = "MIT" +repository = "https://github.com/Reflective-Lab/helms" +publish = true + +[dependencies] +anyhow = "1" +async-trait = "0.1" +axum = "0.8" +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/contracts/crates/helm-module-contracts/src/lib.rs b/contracts/crates/helm-module-contracts/src/lib.rs new file mode 100644 index 0000000..3cb885e --- /dev/null +++ b/contracts/crates/helm-module-contracts/src/lib.rs @@ -0,0 +1,271 @@ +//! Shared contracts for Helm modules mounted into Runtime Runway. +//! +//! Defines both the readiness-reporting vocabulary (`HelmModuleReadiness`, +//! `HelmModuleState`, `HelmModuleStatus`) and the mounting contract +//! (`HelmModule`, `ModuleState`) so the interface lives in a neutral crate +//! that both helms crates and Runtime Runway can consume without creating a +//! foundation→substrate dependency (RP-LAYERING, RFL-128). + +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HelmModuleState { + /// Routes may exist, but the module is serving default/demo/static state. + ShellDefault, + /// The module is backed by live app evidence or executable truth wiring. + Live, +} + +impl HelmModuleState { + pub const fn is_live(self) -> bool { + matches!(self, Self::Live) + } +} + +pub trait HelmModuleReadiness { + fn module_state(&self) -> HelmModuleState; + + fn readiness_status(&self) -> HelmModuleStatus; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HelmModuleStatus { + pub module_id: String, + pub state: HelmModuleState, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub registered_truths: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub live_requirements: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing_live_requirements: Vec, +} + +impl HelmModuleStatus { + pub fn new( + module_id: impl Into, + state: HelmModuleState, + reason: impl Into, + ) -> Self { + Self { + module_id: module_id.into(), + state, + reason: reason.into(), + registered_truths: None, + live_requirements: Vec::new(), + missing_live_requirements: Vec::new(), + } + } + + pub fn with_registered_truths(mut self, count: usize) -> Self { + self.registered_truths = Some(count); + self + } + + pub fn with_live_requirements(mut self, requirements: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.live_requirements = requirements.into_iter().map(Into::into).collect(); + self + } + + pub fn with_missing_live_requirements(mut self, requirements: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.missing_live_requirements = requirements.into_iter().map(Into::into).collect(); + self + } +} + +// ── Mounting contract ───────────────────────────────────────────────────────── +// +// `HelmModule` and `ModuleState` live here so the contract is neutral — neither +// helms crates nor Runtime Runway need to depend on the other to share it. +// Extracted from `runway-app-host::module` (RFL-128; RP-LAYERING). + +/// Whether a mounted module is wired to live state or is still a default shell. +/// +/// The D1 manifest verifier reconciles this against the manifest's +/// `mounted_modules[].mount_kind`: a module the manifest marks `Mounted` must +/// report `Live`, otherwise `serve()` fails. The default is `Shell` so silence +/// fails closed — a module that forgets to report its state is treated as +/// not-yet-wired, never as a live claim that passes the gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModuleState { + /// Mounted but not wired to live state (default-shell). + Shell, + /// Wired to live state. + Live, +} + +/// Contract for a Helm module that can be mounted into Runtime Runway +/// (`RunwayAppHostBuilder::mount`). +/// +/// All methods have default no-op implementations so implementors only need to +/// override what they provide. `module_id` is the one required method. +/// +/// RP-LAYERING (RFL-128): this trait is defined here (a neutral foundation +/// crate) so that both helms crates and `runway-app-host` consume it without +/// either side depending on the other. +#[async_trait] +pub trait HelmModule: Send + Sync + 'static { + fn module_id(&self) -> &'static str; + + /// Called once during host `build()`. Override to register services, + /// validate config, or log readiness evidence. The host aborts startup on + /// `Err`. Default implementation is a no-op. + async fn init(&self) -> anyhow::Result<()> { + Ok(()) + } + + /// Axum router to merge into the host. Default returns an empty router. + fn router(self: Arc) -> axum::Router { + axum::Router::new() + } + + /// Whether this module is wired to live state. The D1 verifier treats a + /// manifest-declared `Mounted` module that reports `Shell` as the + /// planned-vs-mounted lie; `serve()` will be rejected. Defaults to `Shell` + /// (fails closed). + fn module_state(&self) -> ModuleState { + ModuleState::Shell + } +} + +#[cfg(test)] +mod tests { + use super::{HelmModule, HelmModuleReadiness, HelmModuleState, HelmModuleStatus, ModuleState}; + use serde_json::json; + + #[test] + fn shell_default_serializes_as_contract_value() { + let value = serde_json::to_value(HelmModuleState::ShellDefault).unwrap(); + assert_eq!(value, "shell-default"); + } + + #[test] + fn live_state_reports_live() { + assert!(HelmModuleState::Live.is_live()); + assert!(!HelmModuleState::ShellDefault.is_live()); + } + + #[test] + fn status_carries_missing_requirements() { + let status = HelmModuleStatus::new( + "helm.operator-control", + HelmModuleState::ShellDefault, + "live evidence is not wired", + ) + .with_registered_truths(0) + .with_live_requirements(["process_receipt", "integrity_proof"]) + .with_missing_live_requirements(["process_receipt"]); + + assert_eq!(status.registered_truths, Some(0)); + assert_eq!(status.missing_live_requirements, vec!["process_receipt"]); + } + + #[test] + fn status_serializes_verifier_contract_shape() { + let status = HelmModuleStatus::new( + "helm.operator-control", + HelmModuleState::ShellDefault, + "live evidence is not wired", + ) + .with_registered_truths(0) + .with_live_requirements(["process_receipt", "integrity_proof"]) + .with_missing_live_requirements(["process_receipt"]); + + let value = serde_json::to_value(status).unwrap(); + assert_eq!( + value, + json!({ + "module_id": "helm.operator-control", + "state": "shell-default", + "reason": "live evidence is not wired", + "registered_truths": 0, + "live_requirements": ["process_receipt", "integrity_proof"], + "missing_live_requirements": ["process_receipt"] + }) + ); + } + + #[test] + fn live_status_omits_empty_missing_requirements() { + let status = HelmModuleStatus::new( + "helm.governed-jobs", + HelmModuleState::Live, + "truth registry is populated", + ) + .with_registered_truths(3) + .with_live_requirements(["truth_registry"]); + + let value = serde_json::to_value(status).unwrap(); + assert_eq!(value["state"], "live"); + assert_eq!(value["live_requirements"], json!(["truth_registry"])); + assert!(value.get("missing_live_requirements").is_none()); + } + + #[test] + fn readiness_trait_exposes_state_and_status() { + struct TestModule; + + impl HelmModuleReadiness for TestModule { + fn module_state(&self) -> HelmModuleState { + HelmModuleState::ShellDefault + } + + fn readiness_status(&self) -> HelmModuleStatus { + HelmModuleStatus::new("helm.test", self.module_state(), "test shell") + } + } + + let module = TestModule; + assert_eq!(module.module_state(), HelmModuleState::ShellDefault); + assert_eq!(module.readiness_status().module_id, "helm.test"); + } + + #[test] + fn module_state_shell_is_default() { + use std::sync::Arc; + + struct MinimalModule; + + impl HelmModule for MinimalModule { + fn module_id(&self) -> &'static str { + "helm.test-minimal" + } + } + + assert_eq!( + Arc::new(MinimalModule).module_state(), + ModuleState::Shell, + "default module_state must be Shell (fails closed)" + ); + } + + #[tokio::test] + async fn module_init_default_is_noop() { + use std::sync::Arc; + + struct MinimalModule; + + impl HelmModule for MinimalModule { + fn module_id(&self) -> &'static str { + "helm.test-minimal" + } + } + + Arc::new(MinimalModule) + .init() + .await + .expect("init should succeed"); + } +}