diff --git a/crates/idunn-daemon/src/main.rs b/crates/idunn-daemon/src/main.rs index 3870b55..92ff438 100644 --- a/crates/idunn-daemon/src/main.rs +++ b/crates/idunn-daemon/src/main.rs @@ -8,7 +8,7 @@ use odin_core::{ IdunnCommandBoundaryRecord, IdunnDaemonHealthRecord, IdunnDaemonSurgeryPlanRecord, IdunnDaemonTransportProfileRecord, IdunnDeploymentResultRecord, IdunnDesiredDaemonRecord, IdunnOperatorAlarmRecord, IdunnRestartResultRecord, IdunnRudpHealthIngressRecord, - IdunnRuntimeTransportCheckRecord, OdinDocuments, plan_keepalive, + IdunnRuntimeTransportCheckRecord, IdunnSwarmSurgeryPlanRecord, OdinDocuments, plan_keepalive, }; use std::env; use std::net::{SocketAddr, UdpSocket}; @@ -112,7 +112,7 @@ fn run_swarm(options: &SwarmOptions, common: &CommonOptions) -> Result<()> { let now = timestamp()?; publish_runtime_transport_check(common, &store_lock, &now)?; start_rudp_health_ingress(common, &store_lock, &now)?; - publish_daemon_surgery_plans(&targets, common, &store_lock, &now)?; + publish_surgery_plans(&options.profile, &targets, common, &store_lock, &now)?; let mut workers = Vec::with_capacity(targets.len()); for target in targets { @@ -607,14 +607,17 @@ fn health_from_rudp_message(message: &CultNetMessage) -> Result>, updated_at: &str, ) -> Result<()> { + let swarm_plan = swarm_surgery_plan(profile, targets, updated_at); let plans = daemon_surgery_plans(targets, updated_at); with_store_node(options, store_lock, |node| { + node.put(&swarm_plan.plan_id, &swarm_plan)?; for target in targets { let transport_profile = daemon_transport_profile(target, updated_at); let command_boundary = command_boundary(target, updated_at); @@ -628,6 +631,60 @@ fn publish_daemon_surgery_plans( }) } +fn swarm_surgery_plan( + profile: &str, + targets: &[DaemonTarget], + updated_at: &str, +) -> IdunnSwarmSurgeryPlanRecord { + let next_target = if targets + .iter() + .any(|target| target.daemon_id == "starfire-muninn") + { + "starfire-muninn" + } else if let Some(target) = targets.iter().find(|target| target.enabled) { + target.daemon_id.as_str() + } else { + "none" + }; + + IdunnSwarmSurgeryPlanRecord { + plan_id: format!("swarm-surgery:{profile}"), + profile: profile.to_string(), + status: "active-transport-migration".to_string(), + owner: "Idunn swarm supervisor".to_string(), + objective: + "Move daemon awareness from compatibility probes to daemon-published typed CultNet/RUDP state." + .to_string(), + current_mechanism: + "Idunn publishes per-daemon desired state, surgery plans, transport profiles, command boundaries, runtime RUDP self-checks, and RUDP health ingress; compatibility command probes remain fallback evidence until each daemon publishes its own health." + .to_string(), + invariants: vec![ + "Daemon truth is typed CultCache/CultMesh state carried over cultnet.transport.rudp.v0.".to_string(), + "Compatibility commands, HTTP, WebSocket, SSH, and systemd probes are evidence only and must not own daemon health.".to_string(), + "Idunn consumes daemon-published RUDP health before compatibility probes and actuates only advertised lifecycle authority.".to_string(), + "Each migrated daemon must publish the same health contract that Idunn expects for its target.".to_string(), + ], + phases: vec![ + "1. Publish Idunn's own RUDP substrate and ingress state.".to_string(), + "2. Install daemon-published RUDP health in one Rust daemon and prove Idunn consumes it live.".to_string(), + "3. Extend CultLib RUDP publication support across TypeScript, C#, and remaining daemon runtimes.".to_string(), + "4. Promote provider advertisements, command boundaries, and transport profiles to daemon-owned CultNet/RUDP records.".to_string(), + "5. Delete or demote compatibility probes once every target has daemon-owned publication and advertised lifecycle authority.".to_string(), + ], + current_phase: + "Phase 2: install daemon-published RUDP health in Muninn as the first Rust daemon cut." + .to_string(), + next_target: next_target.to_string(), + cut_line: + "The old command-probe path is cut per daemon only after fresh daemon-published RUDP health matches daemon_id, health_contract, publication_source, transport, and freshness window." + .to_string(), + verification_layer: + "CultMesh keepalive store records plus live Idunn decision cycles, not process exit codes or chat summaries." + .to_string(), + updated_at: updated_at.to_string(), + } +} + fn transport_profile_id(target: &DaemonTarget) -> String { format!("transport:{}", target.daemon_id) } @@ -1630,6 +1687,48 @@ mod tests { ); } + #[test] + fn swarm_surgery_plan_names_current_cut_and_verification_layer() { + let muninn = DaemonTarget { + daemon_id: "starfire-muninn".to_string(), + verse_id: "starfire.local".to_string(), + name: "Starfire Muninn".to_string(), + health_contract: health_contract( + "muninn.cultnet-rudp-local-telemetry-and-quest-access", + "degraded", + ), + health_command: Some("health-starfire-muninn.cmd".to_string()), + deploy_command: None, + restart_command: Some("restart-starfire-muninn.cmd".to_string()), + enabled: true, + interval_seconds: 30, + }; + let odin = DaemonTarget { + daemon_id: "odin".to_string(), + verse_id: "starfire.local".to_string(), + name: "Odin".to_string(), + health_contract: health_contract("odin.cultnet-rudp-provider-health", "failed"), + health_command: Some("health-odin.cmd".to_string()), + deploy_command: None, + restart_command: Some("restart-odin.cmd".to_string()), + enabled: true, + interval_seconds: 30, + }; + + let plan = swarm_surgery_plan("starfire-local", &[odin, muninn], "unix:100"); + + assert_eq!(plan.plan_id, "swarm-surgery:starfire-local"); + assert_eq!(plan.status, "active-transport-migration"); + assert_eq!(plan.next_target, "starfire-muninn"); + assert!(plan.current_phase.contains("Muninn")); + assert!(plan.verification_layer.contains("CultMesh keepalive store")); + assert!( + plan.invariants + .iter() + .any(|invariant| invariant.contains("cultnet.transport.rudp.v0")) + ); + } + #[test] fn fresh_daemon_published_rudp_health_can_replace_probe_health() { let desired = IdunnDesiredDaemonRecord { diff --git a/crates/odin-core/src/documents.rs b/crates/odin-core/src/documents.rs index 9c626a8..fb22230 100644 --- a/crates/odin-core/src/documents.rs +++ b/crates/odin-core/src/documents.rs @@ -19,6 +19,7 @@ pub const IDUNN_RESTART_RESULT_SCHEMA: &str = "idunn.restart_result.v1"; pub const IDUNN_DEPLOYMENT_REQUEST_SCHEMA: &str = "idunn.deployment_request.v1"; pub const IDUNN_DEPLOYMENT_RESULT_SCHEMA: &str = "idunn.deployment_result.v1"; pub const IDUNN_OPERATOR_ALARM_SCHEMA: &str = "idunn.operator_alarm.v1"; +pub const IDUNN_SWARM_SURGERY_PLAN_SCHEMA: &str = "idunn.swarm_surgery_plan.v1"; pub const IDUNN_DAEMON_SURGERY_PLAN_SCHEMA: &str = "idunn.daemon_surgery_plan.v1"; pub const IDUNN_DAEMON_TRANSPORT_PROFILE_SCHEMA: &str = "idunn.daemon_transport_profile.v1"; pub const IDUNN_COMMAND_BOUNDARY_SCHEMA: &str = "idunn.command_boundary.v1"; @@ -354,6 +355,40 @@ pub struct IdunnOperatorAlarmRecord { pub raised_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.swarm_surgery_plan", + schema = "idunn.swarm_surgery_plan.v1" +)] +pub struct IdunnSwarmSurgeryPlanRecord { + #[cultcache(key = 0)] + pub plan_id: String, + #[cultcache(key = 1)] + pub profile: String, + #[cultcache(key = 2)] + pub status: String, + #[cultcache(key = 3)] + pub owner: String, + #[cultcache(key = 4)] + pub objective: String, + #[cultcache(key = 5)] + pub current_mechanism: String, + #[cultcache(key = 6)] + pub invariants: Vec, + #[cultcache(key = 7)] + pub phases: Vec, + #[cultcache(key = 8)] + pub current_phase: String, + #[cultcache(key = 9)] + pub next_target: String, + #[cultcache(key = 10)] + pub cut_line: String, + #[cultcache(key = 11)] + pub verification_layer: String, + #[cultcache(key = 12)] + pub updated_at: String, +} + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache( type = "idunn.daemon_surgery_plan", @@ -716,6 +751,7 @@ cultmesh_rs::cultmesh_documents!(OdinDocuments { IdunnDeploymentRequestRecord => IDUNN_DEPLOYMENT_REQUEST_SCHEMA, IdunnDeploymentResultRecord => IDUNN_DEPLOYMENT_RESULT_SCHEMA, IdunnOperatorAlarmRecord => IDUNN_OPERATOR_ALARM_SCHEMA, + IdunnSwarmSurgeryPlanRecord => IDUNN_SWARM_SURGERY_PLAN_SCHEMA, IdunnDaemonSurgeryPlanRecord => IDUNN_DAEMON_SURGERY_PLAN_SCHEMA, IdunnDaemonTransportProfileRecord => IDUNN_DAEMON_TRANSPORT_PROFILE_SCHEMA, IdunnCommandBoundaryRecord => IDUNN_COMMAND_BOUNDARY_SCHEMA, diff --git a/crates/odin-core/src/idunn.rs b/crates/odin-core/src/idunn.rs index d30e822..3448f60 100644 --- a/crates/odin-core/src/idunn.rs +++ b/crates/odin-core/src/idunn.rs @@ -178,8 +178,9 @@ mod tests { use crate::documents::{ IDUNN_COMMAND_BOUNDARY_SCHEMA, IDUNN_DAEMON_SURGERY_PLAN_SCHEMA, IDUNN_DAEMON_TRANSPORT_PROFILE_SCHEMA, IDUNN_DESIRED_DAEMON_SCHEMA, - IdunnCommandBoundaryRecord, IdunnDaemonSurgeryPlanRecord, - IdunnDaemonTransportProfileRecord, IdunnRuntimeTransportCheckRecord, OdinDocuments, + IDUNN_SWARM_SURGERY_PLAN_SCHEMA, IdunnCommandBoundaryRecord, IdunnDaemonSurgeryPlanRecord, + IdunnDaemonTransportProfileRecord, IdunnRuntimeTransportCheckRecord, + IdunnSwarmSurgeryPlanRecord, OdinDocuments, }; use anyhow::Result; use cultmesh_rs::{CultMesh, CultMeshNodeOptions}; @@ -354,6 +355,22 @@ mod tests { blockers: vec!["runtime CultLib update".to_string()], updated_at: "2026-06-04T00:00:02Z".to_string(), }; + let swarm_surgery_plan = IdunnSwarmSurgeryPlanRecord { + plan_id: "swarm-surgery:starfire-local".to_string(), + profile: "starfire-local".to_string(), + status: "active-transport-migration".to_string(), + owner: "Idunn swarm supervisor".to_string(), + objective: "Move daemon awareness to CultNet/RUDP".to_string(), + current_mechanism: "compatibility probes with RUDP fallback".to_string(), + invariants: vec!["daemon truth is typed RUDP state".to_string()], + phases: vec!["install Muninn RUDP health".to_string()], + current_phase: "Phase 2".to_string(), + next_target: "starfire-muninn".to_string(), + cut_line: "cut command probe after RUDP health exists".to_string(), + verification_layer: "CultMesh keepalive store".to_string(), + updated_at: "2026-06-04T00:00:02Z".to_string(), + }; + node.put(&swarm_surgery_plan.plan_id, &swarm_surgery_plan)?; node.put(&surgery_plan.plan_id, &surgery_plan)?; let transport_profile = IdunnDaemonTransportProfileRecord { profile_id: "transport:voidbot".to_string(), @@ -408,6 +425,14 @@ mod tests { .as_deref(), Some(IDUNN_DESIRED_DAEMON_SCHEMA) ); + assert_eq!( + reloaded + .documents() + .binding("idunn.swarm_surgery_plan") + .and_then(|binding| binding.payload_schema_version.clone()) + .as_deref(), + Some(IDUNN_SWARM_SURGERY_PLAN_SCHEMA) + ); assert_eq!( reloaded .documents() @@ -445,6 +470,12 @@ mod tests { .action, "restart" ); + assert_eq!( + reloaded + .get_required::(&swarm_surgery_plan.plan_id)? + .next_target, + "starfire-muninn" + ); assert_eq!( reloaded .get_required::(&surgery_plan.plan_id)? diff --git a/docs/idunn.md b/docs/idunn.md index dd7094c..a28c2fc 100644 --- a/docs/idunn.md +++ b/docs/idunn.md @@ -86,6 +86,7 @@ idunn.deployment_result.v1 idunn.restart_request.v1 idunn.restart_result.v1 idunn.operator_alarm.v1 +idunn.swarm_surgery_plan.v1 idunn.daemon_surgery_plan.v1 idunn.daemon_transport_profile.v1 idunn.command_boundary.v1 @@ -155,7 +156,13 @@ idunn.rudp_health_ingress.v1 contract, `publication_source=daemon-published`, transport `cultnet.transport.rudp.v0`, and `max_silence_seconds` freshness window all match. If any of those fail, the command probe remains fallback evidence. -- Idunn publishes the transport migration plan as +- Idunn publishes the active swarm transport migration plan as + `idunn.swarm_surgery_plan.v1`. That record names the active profile, owner, + objective, current mechanism, invariants, ordered phases, current phase, next + target, cut line, and verification layer. It is the state-surface answer to + "what surgery is Idunn doing next?" and must be lowered by Nightwing/Gjallar + before any chat summary or dashboard prose claims ownership of the plan. +- Idunn publishes per-target transport migration plans as `idunn.daemon_surgery_plan.v1` records in the keepalive store. Each daemon plan names severity, status, owner, objective, current mechanism, intended CultNet/RUDP authority, cut line, ordered steps, blockers, and update time. @@ -228,26 +235,32 @@ The local swarm mode owns: 1. the built-in Starfire-local target catalog; 2. a mandatory health contract per target; -3. a typed `idunn.daemon_surgery_plan.v1` record per target, so transport debt +3. a typed `idunn.swarm_surgery_plan.v1` record, so Idunn's active migration + order, next target, cut line, and verification layer are explicit state; +4. a typed `idunn.daemon_surgery_plan.v1` record per target, so transport debt is visible in the same state surface as daemon health; -4. a typed `idunn.daemon_transport_profile.v1` and +5. a typed `idunn.daemon_transport_profile.v1` and `idunn.command_boundary.v1` record per target, so compatibility probes and local commands cannot pretend to be daemon-owned CultNet/RUDP truth; -5. a startup `idunn.runtime_transport_check.v1` witness proving Idunn's own +6. a startup `idunn.runtime_transport_check.v1` witness proving Idunn's own Rust RUDP loopback path; -6. a local `idunn.rudp_health_ingress.v1` listener for daemon-owned RUDP +7. a local `idunn.rudp_health_ingress.v1` listener for daemon-owned RUDP health publication; -7. one in-process schedule per target instead of one watchdog process per target; -8. shared typed keepalive records in one CultMesh store; -9. deploy/restart/alarm execution through the same Rust decision engine; -10. recovery of fast local targets like Odin without waiting behind slow remote +8. one in-process schedule per target instead of one watchdog process per target; +9. shared typed keepalive records in one CultMesh store; +10. deploy/restart/alarm execution through the same Rust decision engine; +11. recovery of fast local targets like Odin without waiting behind slow remote Yggdrasil checks. -Next: update daemon CultLib dependencies to the cross-runtime -`cultnet.transport.rudp.v0` surface, ingest Odin-owned service/provider -advertisements directly, promote the target catalog out of hardcoded bootstrap -data, add named adapters only for legacy service-manager crossings, then -publish an Eve/CultUI keepalive surface. +Current plan surface: `idunn.swarm_surgery_plan.v1` for profile +`starfire-local` currently points at `starfire-muninn` as the first Rust daemon +cut. The verification layer is the CultMesh keepalive store plus live Idunn +decision cycles, not command exit codes or chat summaries. + +Next: update Muninn's CultLib dependency to publish daemon-owned health through +the cross-runtime `cultnet.transport.rudp.v0` surface, prove Idunn consumes that +live record, then continue runtime-by-runtime until compatibility probes can be +deleted or demoted. No ad hoc JSON manifest, HTTP endpoint, TCP socket, or WebSocket bridge may become the live state owner. Debug projections are fine when they name the