commit 9ba2411d5dab85f4e033bf3f4c8bf7b01ef8a5f3
parent 13a90eeafea2f56e70895c58d24d3e73b063daf3
Author: triesap <tyson@radroots.org>
Date: Wed, 8 Apr 2026 16:55:04 +0000
operability: expose myc runtime contract surfaces
Diffstat:
6 files changed, 168 insertions(+), 7 deletions(-)
diff --git a/.env.example b/.env.example
@@ -18,9 +18,11 @@ MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=10
# MYC_DISCOVERY_NIP05_OUTPUT_PATH=/var/lib/radroots/services/myc/public/.well-known/nostr.json
# set explicit path variables only when overriding the canonical profile-derived locations
MYC_PATHS_SIGNER_IDENTITY_BACKEND=encrypted_file
+# shared backends: encrypted_file, host_vault, external_command, plaintext_file
+# runtime-specific custody mode: managed_account
# encrypted_file and plaintext_file: identity file path
# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME
-# managed_account: account store file path
+# managed_account: account store file path layered over host-vault-backed custody primitives
# external_command: signer helper executable path
# MYC_PATHS_SIGNER_IDENTITY_PATH=
MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=
@@ -28,9 +30,11 @@ MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=
MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.signer
MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH=
MYC_PATHS_USER_IDENTITY_BACKEND=encrypted_file
+# shared backends: encrypted_file, host_vault, external_command, plaintext_file
+# runtime-specific custody mode: managed_account
# encrypted_file and plaintext_file: identity file path
# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME
-# managed_account: account store file path
+# managed_account: account store file path layered over host-vault-backed custody primitives
# external_command: signer helper executable path
# MYC_PATHS_USER_IDENTITY_PATH=
MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID=
@@ -53,9 +57,11 @@ MYC_DISCOVERY_ENABLED=true
MYC_DISCOVERY_DOMAIN=myc.radroots.org
MYC_DISCOVERY_HANDLER_IDENTIFIER=myc
MYC_DISCOVERY_APP_IDENTITY_BACKEND=
+# shared backends: encrypted_file, host_vault, external_command, plaintext_file
+# runtime-specific custody mode: managed_account
# encrypted_file and plaintext_file: identity file path
# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME
-# managed_account: account store file path
+# managed_account: account store file path layered over host-vault-backed custody primitives
# external_command: signer helper executable path
# MYC_DISCOVERY_APP_IDENTITY_PATH=
MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID=
diff --git a/src/config.rs b/src/config.rs
@@ -223,6 +223,18 @@ pub struct MycIdentitySourceSpec {
pub profile_path: Option<PathBuf>,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycRuntimeContractOutput {
+ pub active_profile: MycPathProfile,
+ pub allowed_profiles: Vec<MycPathProfile>,
+ pub default_shared_secret_backend: MycIdentityBackend,
+ pub allowed_shared_secret_backends: Vec<MycIdentityBackend>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub runtime_specific_custody_modes: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub host_vault_policy: Option<String>,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MycTransportDeliveryPolicy {
@@ -437,6 +449,37 @@ impl MycIdentityBackend {
}
}
+const MYC_ALLOWED_PROFILES: [MycPathProfile; 3] = [
+ MycPathProfile::InteractiveUser,
+ MycPathProfile::ServiceHost,
+ MycPathProfile::RepoLocal,
+];
+const MYC_ALLOWED_SHARED_SECRET_BACKENDS: [MycIdentityBackend; 4] = [
+ MycIdentityBackend::EncryptedFile,
+ MycIdentityBackend::HostVault,
+ MycIdentityBackend::ExternalCommand,
+ MycIdentityBackend::PlaintextFile,
+];
+const MYC_RUNTIME_SPECIFIC_CUSTODY_MODES: [&str; 1] = ["managed_account"];
+const MYC_DEFAULT_SHARED_SECRET_BACKEND: MycIdentityBackend = MycIdentityBackend::EncryptedFile;
+const MYC_HOST_VAULT_POLICY: &str = "desktop";
+
+impl MycRuntimeContractOutput {
+ pub fn for_active_profile(active_profile: MycPathProfile) -> Self {
+ Self {
+ active_profile,
+ allowed_profiles: MYC_ALLOWED_PROFILES.to_vec(),
+ default_shared_secret_backend: MYC_DEFAULT_SHARED_SECRET_BACKEND,
+ allowed_shared_secret_backends: MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec(),
+ runtime_specific_custody_modes: MYC_RUNTIME_SPECIFIC_CUSTODY_MODES
+ .into_iter()
+ .map(str::to_owned)
+ .collect(),
+ host_vault_policy: Some(MYC_HOST_VAULT_POLICY.to_owned()),
+ }
+ }
+}
+
impl MycSignerStateBackend {
pub fn as_str(self) -> &'static str {
match self {
@@ -622,6 +665,33 @@ impl MycPathsConfig {
}
impl MycConfig {
+ pub fn allowed_profiles() -> Vec<MycPathProfile> {
+ MYC_ALLOWED_PROFILES.to_vec()
+ }
+
+ pub fn default_shared_secret_backend() -> MycIdentityBackend {
+ MYC_DEFAULT_SHARED_SECRET_BACKEND
+ }
+
+ pub fn allowed_shared_secret_backends() -> Vec<MycIdentityBackend> {
+ MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec()
+ }
+
+ pub fn runtime_specific_custody_modes() -> Vec<String> {
+ MYC_RUNTIME_SPECIFIC_CUSTODY_MODES
+ .into_iter()
+ .map(str::to_owned)
+ .collect()
+ }
+
+ pub fn host_vault_policy() -> Option<String> {
+ Some(MYC_HOST_VAULT_POLICY.to_owned())
+ }
+
+ pub fn runtime_contract_output(&self) -> MycRuntimeContractOutput {
+ MycRuntimeContractOutput::for_active_profile(self.paths.profile)
+ }
+
fn default_with_path_selection(
resolver: &RadrootsPathResolver,
profile: MycPathProfile,
@@ -3227,4 +3297,29 @@ MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite
.contains("MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite")
);
}
+
+ #[test]
+ fn runtime_contract_output_matches_shared_runtime_contract() {
+ let config = MycConfig::default();
+ let contract = config.runtime_contract_output();
+
+ assert_eq!(contract.active_profile, MycPathProfile::InteractiveUser);
+ assert_eq!(contract.allowed_profiles, MycConfig::allowed_profiles());
+ assert_eq!(
+ contract.default_shared_secret_backend,
+ MycConfig::default_shared_secret_backend()
+ );
+ assert_eq!(
+ contract.allowed_shared_secret_backends,
+ MycConfig::allowed_shared_secret_backends()
+ );
+ assert_eq!(
+ contract.runtime_specific_custody_modes,
+ MycConfig::runtime_specific_custody_modes()
+ );
+ assert_eq!(
+ contract.host_vault_policy,
+ MycConfig::host_vault_policy()
+ );
+ }
}
diff --git a/src/custody.rs b/src/custody.rs
@@ -18,7 +18,7 @@ use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
-use crate::config::{MycIdentityBackend, MycIdentitySourceSpec};
+use crate::config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec};
use crate::error::MycError;
use crate::identity_storage::{
load_encrypted_identity, load_identity_profile, rotate_encrypted_identity,
@@ -60,6 +60,13 @@ pub struct MycIdentityStatusOutput {
pub selected_account_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_account_state: Option<MycManagedAccountSelectionState>,
+ pub default_shared_secret_backend: MycIdentityBackend,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub allowed_shared_secret_backends: Vec<MycIdentityBackend>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub runtime_specific_custody_modes: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub host_vault_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1172,6 +1179,10 @@ impl MycIdentityProvider {
selected_account_id: None,
selected_account_label: None,
selected_account_state: None,
+ default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
+ allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
+ runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
+ host_vault_policy: MycConfig::host_vault_policy(),
identity_id: Some(identity.id.to_string()),
public_key_hex: Some(identity.public_key_hex.clone()),
error: None,
@@ -1190,6 +1201,10 @@ impl MycIdentityProvider {
selected_account_id: None,
selected_account_label: None,
selected_account_state: None,
+ default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
+ allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
+ runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
+ host_vault_policy: MycConfig::host_vault_policy(),
identity_id: None,
public_key_hex: None,
error: Some(error.to_string()),
@@ -1246,6 +1261,12 @@ impl MycIdentityProvider {
selected_account_id: None,
selected_account_label: None,
selected_account_state: None,
+ default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
+ allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(
+ ),
+ runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(
+ ),
+ host_vault_policy: MycConfig::host_vault_policy(),
identity_id: None,
public_key_hex: None,
error: Some(error.to_string()),
@@ -1305,6 +1326,10 @@ impl MycIdentityProvider {
selected_account_id,
selected_account_label,
selected_account_state,
+ default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
+ allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
+ runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
+ host_vault_policy: MycConfig::host_vault_policy(),
identity_id,
public_key_hex,
error,
diff --git a/src/lib.rs b/src/lib.rs
@@ -31,7 +31,7 @@ pub use config::{
MycDiscoveryConfig, MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec,
MycLoggingConfig, MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig,
MycPolicyConfig, MycRuntimeAuditBackend, MycServiceConfig, MycSignerStateBackend,
- MycTransportConfig, MycTransportDeliveryPolicy,
+ MycRuntimeContractOutput, MycTransportConfig, MycTransportDeliveryPolicy,
};
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
pub use custody::{
diff --git a/src/operability/mod.rs b/src/operability/mod.rs
@@ -18,7 +18,10 @@ use tokio::task::JoinSet;
use crate::app::MycRuntime;
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome};
-use crate::config::{MycRuntimeAuditBackend, MycSignerStateBackend, MycTransportDeliveryPolicy};
+use crate::config::{
+ MycRuntimeAuditBackend, MycRuntimeContractOutput, MycSignerStateBackend,
+ MycTransportDeliveryPolicy,
+};
use crate::custody::{MycActiveIdentity, MycIdentityStatusOutput};
use crate::discovery::MycDiscoveryContext;
use crate::error::MycError;
@@ -183,6 +186,7 @@ pub struct MycStatusFullOutput {
pub status: MycRuntimeStatus,
pub ready: bool,
pub reasons: Vec<String>,
+ pub runtime_contract: MycRuntimeContractOutput,
pub startup: crate::app::MycStartupSnapshot,
pub signer_backend: MycSignerBackendStatusOutput,
pub custody: MycCustodyStatusOutput,
@@ -198,6 +202,7 @@ pub struct MycStatusSummaryOutput {
pub ready: bool,
pub reasons: Vec<String>,
pub instance_name: String,
+ pub runtime_contract: MycRuntimeContractOutput,
pub signer_backend: MycSignerBackendStatusOutput,
pub custody: MycCustodyStatusOutput,
pub persistence: MycPersistenceStatusOutput,
@@ -352,6 +357,7 @@ pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOu
status,
ready,
reasons,
+ runtime_contract: runtime.config().runtime_contract_output(),
startup: snapshot,
signer_backend,
custody: custody.output,
@@ -371,6 +377,7 @@ pub async fn collect_status_summary(
ready: full.ready,
reasons: full.reasons,
instance_name: full.startup.instance_name,
+ runtime_contract: full.runtime_contract,
signer_backend: MycSignerBackendStatusOutput {
local_signer: full.signer_backend.local_signer.clone(),
remote_session_count: full.signer_backend.remote_session_count,
diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs
@@ -8,7 +8,7 @@ use myc::{
};
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKind};
-use serde_json::Value;
+use serde_json::{Value, json};
fn write_test_identity(path: &Path, secret_key: &str) {
let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret");
@@ -79,6 +79,24 @@ fn status_summary_command_emits_machine_readable_json() {
let value: Value = serde_json::from_slice(&output.stdout).expect("status json");
assert_eq!(value["status"], "unready");
assert_eq!(value["ready"], false);
+ assert_eq!(value["runtime_contract"]["active_profile"], "interactive_user");
+ assert_eq!(
+ value["runtime_contract"]["allowed_profiles"],
+ json!(["interactive_user", "service_host", "repo_local"])
+ );
+ assert_eq!(
+ value["runtime_contract"]["default_shared_secret_backend"],
+ "encrypted_file"
+ );
+ assert_eq!(
+ value["runtime_contract"]["allowed_shared_secret_backends"],
+ json!(["encrypted_file", "host_vault", "external_command", "plaintext_file"])
+ );
+ assert_eq!(
+ value["runtime_contract"]["runtime_specific_custody_modes"],
+ json!(["managed_account"])
+ );
+ assert_eq!(value["runtime_contract"]["host_vault_policy"], "desktop");
assert_eq!(value["custody"]["signer"]["backend"], "encrypted_file");
assert_eq!(value["custody"]["signer"]["resolved"], true);
assert_eq!(value["persistence"]["signer_state"]["backend"], "json_file");
@@ -177,6 +195,16 @@ fn custody_status_command_reports_role_backend_details() {
let value: Value = serde_json::from_slice(&output.stdout).expect("custody status json");
assert_eq!(value["backend"], "encrypted_file");
assert_eq!(value["resolved"], true);
+ assert_eq!(value["default_shared_secret_backend"], "encrypted_file");
+ assert_eq!(
+ value["allowed_shared_secret_backends"],
+ json!(["encrypted_file", "host_vault", "external_command", "plaintext_file"])
+ );
+ assert_eq!(
+ value["runtime_specific_custody_modes"],
+ json!(["managed_account"])
+ );
+ assert_eq!(value["host_vault_policy"], "desktop");
assert_eq!(
value["identity_id"],
"4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"