From 6aba9a880162ec8ce885ee66161d72cd6ff7f8be Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 00:20:38 +0200 Subject: [PATCH 01/34] Delete odin-core's Idunn lifecycle planner and idunn.* schemas crates/odin-core/src/idunn.rs (plan_keepalive: observe, alarm, deploy or restart from IdunnDaemonHealthRecord) had no caller: odin-daemon does not depend on odin-core, and the deployed recipe builds only odin-daemon. Idunn owns deployment and daemon survival; nothing in Idunn reads these records (its own live in cultnet-rs). Also removes from documents.rs the idunn.* and bifrost release-authority schema constants and records, their OdinDocuments registrations, the cultnet re-exports of Idunn health types, and the tests that only exercised them. The muninn.* copies stay. cultnet-rs and rmp-serde drop out of odin-core's manifest and lockfile entry (no remaining user). Muninn pins odin-core at 3e96c6c and is unaffected until its next bump, which must drop IdunnDaemonHealthRecord. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 2 - crates/odin-core/Cargo.toml | 2 - crates/odin-core/src/documents.rs | 1387 ----------------------------- crates/odin-core/src/idunn.rs | 672 -------------- crates/odin-core/src/lib.rs | 2 - 5 files changed, 2065 deletions(-) delete mode 100644 crates/odin-core/src/idunn.rs diff --git a/Cargo.lock b/Cargo.lock index a7be92d..dc99c86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,9 +589,7 @@ dependencies = [ "anyhow", "cultcache-rs", "cultmesh-rs", - "cultnet-rs", "pretty_assertions", - "rmp-serde", "serde", "serde_json", "tempfile", diff --git a/crates/odin-core/Cargo.toml b/crates/odin-core/Cargo.toml index 4083dfa..f7a0cc8 100644 --- a/crates/odin-core/Cargo.toml +++ b/crates/odin-core/Cargo.toml @@ -14,10 +14,8 @@ anyhow = "1" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultcache-rs" } cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultmesh-rs" } serde_json = "1" -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultnet-rs" } serde = { version = "1", features = ["derive"] } [dev-dependencies] -rmp-serde = "1.3" pretty_assertions = "1.4" tempfile = "3" diff --git a/crates/odin-core/src/documents.rs b/crates/odin-core/src/documents.rs index 8347e4e..811685c 100644 --- a/crates/odin-core/src/documents.rs +++ b/crates/odin-core/src/documents.rs @@ -1,20 +1,6 @@ use cultcache_rs::DatabaseEntry; -pub use cultnet_rs::{ - GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA, GameCultServiceTrustAnchorRecord, - IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA, - IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE, - IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA, IDUNN_PROVIDER_ACTIVE_REASON, - IDUNN_PROVIDER_DEGRADED_REASON, IDUNN_PROVIDER_FAILED_REASON, IDUNN_PROVIDER_WARMING_REASON, - IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA, IdunnAuthenticatedProviderHealthProjectionRecord, - IdunnDaemonHealthTrustBindingRecord, IdunnSignedDaemonHealthRecord, - authenticated_provider_health_reason_code, -}; -#[cfg(test)] -use cultnet_rs::{GameCultProviderHealthIdentity, derive_service_identity_id}; use serde_json::Value; -use anyhow::{Result, bail}; - pub const ODIN_SNAPSHOT_SCHEMA: &str = "odin.snapshot.v1"; pub const ODIN_VERSE_SCHEMA: &str = "odin.verse.v1"; pub const ODIN_SERVICE_SCHEMA: &str = "odin.service.v1"; @@ -25,39 +11,6 @@ pub const EVE_SURFACE_STATE_SCHEMA: &str = "gamecult.eve.surface_state.v1"; pub const EVE_INTERFACE_BINDING_SCHEMA: &str = "gamecult.eve.interface_binding.v1"; pub const EVE_PROVIDER_ADVERTISEMENT_SCHEMA: &str = "gamecult.eve.provider_advertisement.v1"; pub const VOIDBOT_SWARM_STATE_SNAPSHOT_SCHEMA: &str = "voidbot.swarm_state_snapshot.v1"; -pub const IDUNN_DESIRED_DAEMON_SCHEMA: &str = "idunn.desired_daemon.v1"; -pub const IDUNN_DAEMON_HEALTH_SCHEMA: &str = "idunn.daemon_health.v1"; -pub const IDUNN_SIGNED_HEALTH_ADMISSION_SCHEMA: &str = "idunn.signed_health_admission.v1"; -pub const IDUNN_KEEPALIVE_DECISION_SCHEMA: &str = "idunn.keepalive_decision.v1"; -pub const IDUNN_RESTART_REQUEST_SCHEMA: &str = "idunn.restart_request.v1"; -pub const IDUNN_RESTART_RESULT_SCHEMA: &str = "idunn.restart_result.v1"; -pub const IDUNN_DEPLOYMENT_REQUEST_SCHEMA: &str = "idunn.deployment_request.v2"; -pub const IDUNN_CURRENT_DEPLOYMENT_REQUEST_SCHEMA: &str = "idunn.current_deployment_request.v1"; -pub const IDUNN_DEPLOYMENT_RESULT_SCHEMA: &str = "idunn.deployment_result.v1"; -pub const IDUNN_LIFECYCLE_COMMAND_SCHEMA: &str = "idunn.lifecycle_command.v1"; -pub const IDUNN_RELEASE_TARGET_SCHEMA: &str = "idunn.release_target.v3"; -pub const IDUNN_DEPLOYMENT_ARTIFACT_SCHEMA: &str = "idunn.deployment_artifact.v2"; -pub const BIFROST_REPOSITORY_RELEASE_AUTHORITY_SCHEMA: &str = - "bifrost.repository_release_authority.v1"; -pub const IDUNN_STATE_MIGRATION_PLAN_SCHEMA: &str = "idunn.state_migration_plan.v1"; -pub const IDUNN_STATE_MIGRATION_RESULT_SCHEMA: &str = "idunn.state_migration_result.v1"; -pub const IDUNN_ROLLOUT_PLAN_SCHEMA: &str = "idunn.rollout_plan.v1"; -pub const IDUNN_ROLLOUT_RESULT_SCHEMA: &str = "idunn.rollout_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"; -pub const IDUNN_RUNTIME_TRANSPORT_CHECK_SCHEMA: &str = "idunn.runtime_transport_check.v1"; -pub const IDUNN_RUDP_HEALTH_INGRESS_SCHEMA: &str = "idunn.rudp_health_ingress.v1"; -pub const IDUNN_AUTHENTICATED_DAEMON_HEALTH_ADMISSION_SCHEMA: &str = - "idunn.authenticated_daemon_health_admission.v1"; -pub const IDUNN_RUNTIME_TRAFFIC_ADMISSION_SCHEMA: &str = "idunn.runtime_traffic_admission.v2"; -pub const IDUNN_CODEX_CONNECTOR_RUNTIME_TRAFFIC_ADMISSION_RECORD_KEY: &str = - "yggdrasil-codex-connector"; -pub const IDUNN_GHOSTLIGHT_RUNTIME_TRAFFIC_ADMISSION_RECORD_KEY: &str = "yggdrasil-ghostlight"; -pub const IDUNN_UNSIGNED_DAEMON_HEALTH_DIAGNOSTIC_SCHEMA: &str = - "idunn.unsigned_daemon_health_diagnostic.v1"; pub const MUNINN_TELEMETRY_SURFACE_SCHEMA: &str = "muninn.telemetry_surface.v1"; pub const MUNINN_CAPTURE_STREAM_SCHEMA: &str = "muninn.capture_stream.v1"; pub const MUNINN_CAPTURE_STREAM_COMMAND_SCHEMA: &str = "muninn.capture_stream_command.v1"; @@ -245,900 +198,6 @@ pub struct VoidBotSwarmStateSnapshotCompatRecord { pub value: Value, } -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.desired_daemon", schema = "idunn.desired_daemon.v1")] -pub struct IdunnDesiredDaemonRecord { - #[cultcache(key = 0)] - pub daemon_id: String, - #[cultcache(key = 1)] - pub verse_id: String, - #[cultcache(key = 2)] - pub name: String, - #[cultcache(key = 3)] - pub enabled: bool, - #[cultcache(key = 4)] - pub health_command: Option, - #[cultcache(key = 5)] - pub restart_command: Option, - #[cultcache(key = 6)] - pub authority: String, - #[cultcache(key = 7)] - pub max_silence_seconds: u32, - #[cultcache(key = 8)] - pub observed_at: String, - #[cultcache(key = 9)] - pub deploy_command: Option, - #[cultcache(key = 10)] - pub health_contract: String, - #[cultcache(key = 11, default)] - pub transport_profile_id: String, - #[cultcache(key = 12, default)] - pub command_boundary_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.daemon_health", schema = "idunn.daemon_health.v1")] -pub struct IdunnDaemonHealthRecord { - #[cultcache(key = 0)] - pub daemon_id: String, - #[cultcache(key = 1)] - pub state: String, - #[cultcache(key = 2)] - pub detail: String, - #[cultcache(key = 3)] - pub observed_at: String, - #[cultcache(key = 4)] - pub health_contract: String, - #[cultcache(key = 5, default)] - pub publication_source: String, - #[cultcache(key = 6, default)] - pub transport: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.signed_health_admission", - schema = "idunn.signed_health_admission.v1" -)] -pub struct IdunnSignedHealthAdmissionRecord { - #[cultcache(key = 0)] - pub daemon_id: String, - #[cultcache(key = 1)] - pub state: String, - #[cultcache(key = 2)] - pub observed_at: String, - #[cultcache(key = 3)] - pub admitted_at: String, - #[cultcache(key = 4)] - pub health_contract: String, - #[cultcache(key = 5)] - pub deployment_request_id: String, - #[cultcache(key = 6)] - pub release_id: String, - #[cultcache(key = 7)] - pub release_witness_sha256: String, - #[cultcache(key = 8)] - pub source_commit: String, - #[cultcache(key = 9)] - pub publisher_incarnation_id: String, - #[cultcache(key = 10)] - pub publisher_sequence: u64, - #[cultcache(key = 11)] - pub publisher_process_created_at: String, - #[cultcache(key = 12)] - pub signer_identity_id: String, - #[cultcache(key = 13)] - pub signed_health_sha256: String, -} - -/// Idunn-owned judgment joining one verified provider statement to the exact -/// root trust binding used to admit it. Managed-health reads must rejoin this -/// record with both current objects; a health row by itself is not authority. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.authenticated_daemon_health_admission", - schema = "idunn.authenticated_daemon_health_admission.v1" -)] -pub struct IdunnAuthenticatedDaemonHealthAdmissionRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub health_contract: String, - #[cultcache(key = 3)] - pub source_runtime_id: String, - #[cultcache(key = 4)] - pub state: String, - #[cultcache(key = 5)] - pub observed_at_unix_millis: u64, - #[cultcache(key = 6)] - pub admitted_at_unix_millis: u64, - #[cultcache(key = 7)] - pub trust_binding_id: String, - #[cultcache(key = 8)] - pub trust_binding_sha256: String, - #[cultcache(key = 9)] - pub signer_identity_id: String, - #[cultcache(key = 10)] - pub publisher_incarnation_id: String, - #[cultcache(key = 11)] - pub publisher_sequence: u64, - #[cultcache(key = 12)] - pub signed_health_sha256: String, - #[cultcache(key = 13)] - pub release_id: Option, - #[cultcache(key = 14)] - pub release_witness_sha256: Option, - #[cultcache(key = 15)] - pub source_commit: Option, - #[cultcache(key = 16)] - pub deployment_id: Option, - #[cultcache(key = 17)] - pub private_state_exposed: bool, - #[cultcache(key = 18, default)] - pub activation_witness_sha256: Option, -} - -impl IdunnAuthenticatedDaemonHealthAdmissionRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_AUTHENTICATED_DAEMON_HEALTH_ADMISSION_SCHEMA { - bail!("authenticated daemon health admission schema is unsupported"); - } - validate_identifier(&self.daemon_id, "daemon id")?; - validate_identifier(&self.health_contract, "health contract")?; - validate_identifier(&self.source_runtime_id, "source runtime id")?; - validate_identifier(&self.trust_binding_id, "trust binding id")?; - validate_identifier(&self.signer_identity_id, "signer identity id")?; - validate_identifier(&self.publisher_incarnation_id, "publisher incarnation id")?; - if !matches!( - self.state.as_str(), - "active" | "warming" | "degraded" | "failed" - ) || self.observed_at_unix_millis == 0 - || self.admitted_at_unix_millis < self.observed_at_unix_millis - || self.publisher_sequence == 0 - || !is_sha256(&self.trust_binding_sha256) - || !is_sha256(&self.signed_health_sha256) - { - bail!("authenticated daemon health admission shape is invalid"); - } - validate_optional_release_binding( - &self.release_id, - &self.release_witness_sha256, - &self.source_commit, - )?; - validate_optional_identifier(&self.deployment_id, "deployment id")?; - if self - .activation_witness_sha256 - .as_deref() - .is_some_and(|value| !is_sha256(value)) - { - bail!("authenticated daemon health activation witness is malformed"); - } - if self.private_state_exposed { - bail!("authenticated daemon health admission exposes private state"); - } - Ok(()) - } -} - -/// Idunn-issued traffic authority for a release that has proved its exact -/// deployment, activation, and authenticated provider-health lineage. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.runtime_traffic_admission", - schema = "idunn.runtime_traffic_admission.v2" -)] -pub struct IdunnRuntimeTrafficAdmissionRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub release_id: String, - #[cultcache(key = 3)] - pub release_witness_sha256: String, - #[cultcache(key = 4)] - pub source_commit: String, - #[cultcache(key = 5)] - pub deployment_id: String, - #[cultcache(key = 6)] - pub activation_witness_sha256: String, - #[cultcache(key = 7)] - pub signed_health_sha256: String, - #[cultcache(key = 8)] - pub publisher_incarnation_id: String, - #[cultcache(key = 9)] - pub publisher_sequence: u64, - #[cultcache(key = 10)] - pub signer_identity_id: String, - #[cultcache(key = 11)] - pub runtime_process_id: u32, - #[cultcache(key = 12)] - pub runtime_process_starttime_ticks: u64, -} - -impl IdunnRuntimeTrafficAdmissionRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_RUNTIME_TRAFFIC_ADMISSION_SCHEMA { - bail!("runtime traffic admission schema is unsupported"); - } - if self.daemon_id != IDUNN_CODEX_CONNECTOR_RUNTIME_TRAFFIC_ADMISSION_RECORD_KEY - && self.daemon_id != IDUNN_GHOSTLIGHT_RUNTIME_TRAFFIC_ADMISSION_RECORD_KEY - { - bail!("runtime traffic admission daemon is unsupported"); - } - validate_identifier(&self.daemon_id, "daemon id")?; - validate_identifier(&self.release_id, "release id")?; - validate_identifier(&self.deployment_id, "deployment id")?; - validate_identifier(&self.publisher_incarnation_id, "publisher incarnation id")?; - validate_identifier(&self.signer_identity_id, "signer identity id")?; - if !is_sha256(&self.release_witness_sha256) - || !is_lower_hex(&self.source_commit, 40) - || !is_sha256(&self.activation_witness_sha256) - || !is_sha256(&self.signed_health_sha256) - || self.publisher_sequence == 0 - || self.runtime_process_id == 0 - || self.runtime_process_starttime_ticks == 0 - { - bail!("runtime traffic admission lineage is malformed"); - } - Ok(()) - } -} - -/// Quarantined observation of the retired unsigned wire contract. This may -/// explain missing authenticated health but can never satisfy managed health. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.unsigned_daemon_health_diagnostic", - schema = "idunn.unsigned_daemon_health_diagnostic.v1" -)] -pub struct IdunnUnsignedDaemonHealthDiagnosticRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub diagnostic_id: String, - #[cultcache(key = 2)] - pub daemon_id: String, - #[cultcache(key = 3)] - pub claimed_state: String, - #[cultcache(key = 4)] - pub claimed_detail: String, - #[cultcache(key = 5)] - pub claimed_observed_at: String, - #[cultcache(key = 6)] - pub claimed_health_contract: String, - #[cultcache(key = 7)] - pub transport_source_runtime_id: Option, - #[cultcache(key = 8)] - pub transport_source_role: Option, - #[cultcache(key = 9)] - pub received_at_unix_millis: u64, - #[cultcache(key = 10)] - pub authority: String, - #[cultcache(key = 11)] - pub private_state_exposed: bool, -} - -impl IdunnUnsignedDaemonHealthDiagnosticRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_UNSIGNED_DAEMON_HEALTH_DIAGNOSTIC_SCHEMA { - bail!("unsigned daemon health diagnostic schema is unsupported"); - } - validate_identifier(&self.diagnostic_id, "diagnostic id")?; - validate_identifier(&self.daemon_id, "daemon id")?; - validate_identifier(&self.claimed_state, "claimed state")?; - validate_identifier(&self.claimed_health_contract, "claimed health contract")?; - validate_optional_identifier( - &self.transport_source_runtime_id, - "transport source runtime id", - )?; - validate_optional_identifier(&self.transport_source_role, "transport source role")?; - if self.claimed_detail.len() > 512 - || self.claimed_observed_at.trim().is_empty() - || self.received_at_unix_millis == 0 - || self.authority != "diagnostic-only" - || self.private_state_exposed - { - bail!("unsigned daemon health diagnostic shape is invalid"); - } - Ok(()) - } -} - -fn validate_identifier(value: &str, label: &str) -> Result<()> { - if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { - bail!("{label} is empty, oversized, or contains control characters"); - } - Ok(()) -} - -fn validate_optional_identifier(value: &Option, label: &str) -> Result<()> { - if let Some(value) = value { - validate_identifier(value, label)?; - } - Ok(()) -} - -fn validate_optional_release_binding( - release_id: &Option, - witness: &Option, - source_commit: &Option, -) -> Result<()> { - if release_id.is_some() || witness.is_some() || source_commit.is_some() { - validate_optional_identifier(release_id, "release id")?; - let witness = witness - .as_deref() - .ok_or_else(|| anyhow::anyhow!("release witness is absent"))?; - let commit = source_commit - .as_deref() - .ok_or_else(|| anyhow::anyhow!("source commit is absent"))?; - if release_id.is_none() || !is_sha256(witness) || !is_lower_hex(commit, 40) { - bail!("release binding is incomplete or malformed"); - } - } - Ok(()) -} - -fn is_sha256(value: &str) -> bool { - value - .strip_prefix("sha256-") - .is_some_and(|digest| is_lower_hex(digest, 64)) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - value.len() == length - && value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.keepalive_decision", - schema = "idunn.keepalive_decision.v1" -)] -pub struct IdunnKeepaliveDecisionRecord { - #[cultcache(key = 0)] - pub decision_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub action: String, - #[cultcache(key = 3)] - pub reason: String, - #[cultcache(key = 4)] - pub authority: String, - #[cultcache(key = 5)] - pub decided_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.restart_request", schema = "idunn.restart_request.v1")] -pub struct IdunnRestartRequestRecord { - #[cultcache(key = 0)] - pub request_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub command: String, - #[cultcache(key = 3)] - pub authority: String, - #[cultcache(key = 4)] - pub requested_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.restart_result", schema = "idunn.restart_result.v1")] -pub struct IdunnRestartResultRecord { - #[cultcache(key = 0)] - pub result_id: String, - #[cultcache(key = 1)] - pub request_id: String, - #[cultcache(key = 2)] - pub daemon_id: String, - #[cultcache(key = 3)] - pub state: String, - #[cultcache(key = 4)] - pub detail: String, - #[cultcache(key = 5)] - pub completed_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.deployment_request", - schema = "idunn.deployment_request.v2" -)] -pub struct IdunnDeploymentRequestRecord { - #[cultcache(key = 0)] - pub request_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub command: String, - #[cultcache(key = 3)] - pub authority: String, - #[cultcache(key = 4)] - pub requested_at: String, - #[cultcache(key = 5, default)] - pub repository_full_name: String, - #[cultcache(key = 6, default)] - pub upstream_ref: String, - #[cultcache(key = 7, default)] - pub source_revision: String, - #[cultcache(key = 8, default)] - pub release_authority_id: String, - #[cultcache(key = 9, default)] - pub release_authority_envelope_sha256: String, - #[cultcache(key = 10, default)] - pub requires_bifrost_authority: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.current_deployment_request", - schema = "idunn.current_deployment_request.v1" -)] -pub struct IdunnCurrentDeploymentRequestRecord { - #[cultcache(key = 0)] - pub daemon_id: String, - #[cultcache(key = 1)] - pub request_id: String, - #[cultcache(key = 2)] - pub sequence: u64, - #[cultcache(key = 3)] - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.deployment_result", - schema = "idunn.deployment_result.v1" -)] -pub struct IdunnDeploymentResultRecord { - #[cultcache(key = 0)] - pub result_id: String, - #[cultcache(key = 1)] - pub request_id: String, - #[cultcache(key = 2)] - pub daemon_id: String, - #[cultcache(key = 3)] - pub state: String, - #[cultcache(key = 4)] - pub detail: String, - #[cultcache(key = 5)] - pub completed_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.lifecycle_command", - schema = "idunn.lifecycle_command.v1" -)] -pub struct IdunnLifecycleCommandRecord { - #[cultcache(key = 0)] - pub command_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub action: String, - #[cultcache(key = 3)] - pub state: String, - #[cultcache(key = 4)] - pub requested_by: String, - #[cultcache(key = 5)] - pub requested_at: String, - #[cultcache(key = 6)] - pub detail: String, - #[cultcache(key = 7, default)] - pub claimed_at: String, - #[cultcache(key = 8, default)] - pub result_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.release_target", schema = "idunn.release_target.v3")] -pub struct IdunnReleaseTargetRecord { - #[cultcache(key = 0)] - pub target_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub repo: String, - #[cultcache(key = 3)] - pub repo_path: String, - #[cultcache(key = 4)] - pub upstream_remote: String, - #[cultcache(key = 5)] - pub upstream_branch: String, - #[cultcache(key = 6)] - pub desired_revision: String, - #[cultcache(key = 7)] - pub deployed_revision: String, - #[cultcache(key = 8)] - pub artifact_strategy: String, - #[cultcache(key = 9)] - pub rollout_strategy: String, - #[cultcache(key = 10)] - pub state_migration_authority: String, - #[cultcache(key = 11)] - pub zero_downtime_capability: String, - #[cultcache(key = 12)] - pub status: String, - #[cultcache(key = 13)] - pub observed_at: String, - #[cultcache(key = 14, default)] - pub repository_full_name: String, - #[cultcache(key = 15, default)] - pub upstream_ref: String, - #[cultcache(key = 16, default)] - pub release_authority_id: String, - #[cultcache(key = 17, default)] - pub release_authority_envelope_sha256: String, - #[cultcache(key = 18, default)] - pub release_authority_status: String, - #[cultcache(key = 19, default)] - pub requires_bifrost_authority: bool, - #[cultcache(key = 20, default)] - pub observed_upstream_revision: String, - #[cultcache(key = 21)] - pub minimum_source_revision: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.deployment_artifact", - schema = "idunn.deployment_artifact.v2" -)] -pub struct IdunnDeploymentArtifactRecord { - #[cultcache(key = 0)] - pub artifact_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub source_revision: String, - #[cultcache(key = 3)] - pub source_branch: String, - #[cultcache(key = 4)] - pub source_remote: String, - #[cultcache(key = 5)] - pub artifact_kind: String, - #[cultcache(key = 6)] - pub artifact_uri: String, - #[cultcache(key = 7)] - pub sha256: String, - #[cultcache(key = 8)] - pub built_at: String, - #[cultcache(key = 9, default)] - pub release_authority_id: String, - #[cultcache(key = 10, default)] - pub release_authority_envelope_sha256: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "bifrost.repository_release_authority", - schema = "bifrost.repository_release_authority.v1" -)] -pub struct BifrostRepositoryReleaseAuthorityRecord { - #[cultcache(key = 0)] - pub authority_id: String, - #[cultcache(key = 1)] - pub command_id: String, - #[cultcache(key = 2)] - pub crossing_receipt_id: String, - #[cultcache(key = 3)] - pub repository_full_name: String, - #[cultcache(key = 4)] - pub upstream_ref: String, - #[cultcache(key = 5)] - pub commit_sha: String, - #[cultcache(key = 6)] - pub decision: String, - #[cultcache(key = 7)] - pub status: String, - #[cultcache(key = 8)] - pub policy_decision_id: String, - #[cultcache(key = 9)] - pub authority_reference: String, - #[cultcache(key = 10)] - pub actor_identity: String, - #[cultcache(key = 11)] - pub source_kind: String, - #[cultcache(key = 12)] - pub source_id: String, - #[cultcache(key = 13, default)] - pub epiphany_run_id: String, - #[cultcache(key = 14, default)] - pub epiphany_lane_id: String, - #[cultcache(key = 15, default)] - pub epiphany_agent_identity: String, - #[cultcache(key = 16)] - pub external_receipt_url: String, - #[cultcache(key = 17)] - pub external_receipt_id: String, - #[cultcache(key = 18)] - pub authorized_at: String, - #[cultcache(key = 19, default)] - pub expires_at: String, - #[cultcache(key = 20, default)] - pub revoked_at: String, - #[cultcache(key = 21, default)] - pub revocation_reason: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.state_migration_plan", - schema = "idunn.state_migration_plan.v1" -)] -pub struct IdunnStateMigrationPlanRecord { - #[cultcache(key = 0)] - pub plan_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub from_schema_version: String, - #[cultcache(key = 3)] - pub to_schema_version: String, - #[cultcache(key = 4)] - pub authority: String, - #[cultcache(key = 5)] - pub command: String, - #[cultcache(key = 6)] - pub strategy: String, - #[cultcache(key = 7)] - pub backup_required: bool, - #[cultcache(key = 8)] - pub zero_downtime_required: bool, - #[cultcache(key = 9)] - pub status: String, - #[cultcache(key = 10)] - pub planned_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.state_migration_result", - schema = "idunn.state_migration_result.v1" -)] -pub struct IdunnStateMigrationResultRecord { - #[cultcache(key = 0)] - pub result_id: String, - #[cultcache(key = 1)] - pub plan_id: String, - #[cultcache(key = 2)] - pub daemon_id: String, - #[cultcache(key = 3)] - pub state: String, - #[cultcache(key = 4)] - pub detail: String, - #[cultcache(key = 5)] - pub completed_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.rollout_plan", schema = "idunn.rollout_plan.v1")] -pub struct IdunnRolloutPlanRecord { - #[cultcache(key = 0)] - pub plan_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub desired_revision: String, - #[cultcache(key = 3)] - pub deployed_revision: String, - #[cultcache(key = 4)] - pub strategy: String, - #[cultcache(key = 5)] - pub phases: Vec, - #[cultcache(key = 6)] - pub migration_plan_id: String, - #[cultcache(key = 7)] - pub artifact_id: String, - #[cultcache(key = 8)] - pub status: String, - #[cultcache(key = 9)] - pub planned_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.rollout_result", schema = "idunn.rollout_result.v1")] -pub struct IdunnRolloutResultRecord { - #[cultcache(key = 0)] - pub result_id: String, - #[cultcache(key = 1)] - pub plan_id: String, - #[cultcache(key = 2)] - pub daemon_id: String, - #[cultcache(key = 3)] - pub state: String, - #[cultcache(key = 4)] - pub detail: String, - #[cultcache(key = 5)] - pub completed_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.operator_alarm", schema = "idunn.operator_alarm.v1")] -pub struct IdunnOperatorAlarmRecord { - #[cultcache(key = 0)] - pub alarm_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub severity: String, - #[cultcache(key = 3)] - pub reason: String, - #[cultcache(key = 4)] - pub escalation_target: String, - #[cultcache(key = 5)] - 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", - schema = "idunn.daemon_surgery_plan.v1" -)] -pub struct IdunnDaemonSurgeryPlanRecord { - #[cultcache(key = 0)] - pub plan_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub severity: String, - #[cultcache(key = 3)] - pub status: String, - #[cultcache(key = 4)] - pub owner: String, - #[cultcache(key = 5)] - pub objective: String, - #[cultcache(key = 6)] - pub current_mechanism: String, - #[cultcache(key = 7)] - pub intended_authority: String, - #[cultcache(key = 8)] - pub cut_line: String, - #[cultcache(key = 9)] - pub steps: Vec, - #[cultcache(key = 10)] - pub blockers: Vec, - #[cultcache(key = 11)] - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.daemon_transport_profile", - schema = "idunn.daemon_transport_profile.v1" -)] -pub struct IdunnDaemonTransportProfileRecord { - #[cultcache(key = 0)] - pub profile_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub target_transport: String, - #[cultcache(key = 3)] - pub current_transport: String, - #[cultcache(key = 4)] - pub state: String, - #[cultcache(key = 5)] - pub health_contract: String, - #[cultcache(key = 6)] - pub publication_schema: String, - #[cultcache(key = 7)] - pub debug_mechanism: String, - #[cultcache(key = 8)] - pub cut_line: String, - #[cultcache(key = 9)] - pub observed_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.command_boundary", schema = "idunn.command_boundary.v1")] -pub struct IdunnCommandBoundaryRecord { - #[cultcache(key = 0)] - pub boundary_id: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub owner: String, - #[cultcache(key = 3)] - pub restart_authority: String, - #[cultcache(key = 4)] - pub deploy_authority: String, - #[cultcache(key = 5)] - pub health_authority: String, - #[cultcache(key = 6)] - pub alarm_authority: String, - #[cultcache(key = 7)] - pub command_lowerings: Vec, - #[cultcache(key = 8)] - pub forbidden_authority: String, - #[cultcache(key = 9)] - pub observed_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.runtime_transport_check", - schema = "idunn.runtime_transport_check.v1" -)] -pub struct IdunnRuntimeTransportCheckRecord { - #[cultcache(key = 0)] - pub check_id: String, - #[cultcache(key = 1)] - pub runtime_id: String, - #[cultcache(key = 2)] - pub transport: String, - #[cultcache(key = 3)] - pub state: String, - #[cultcache(key = 4)] - pub detail: String, - #[cultcache(key = 5)] - pub observed_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.rudp_health_ingress", - schema = "idunn.rudp_health_ingress.v1" -)] -pub struct IdunnRudpHealthIngressRecord { - #[cultcache(key = 0)] - pub ingress_id: String, - #[cultcache(key = 1)] - pub bind_address: String, - #[cultcache(key = 2)] - pub transport: String, - #[cultcache(key = 3)] - pub accepted_schema: String, - #[cultcache(key = 4)] - pub state: String, - #[cultcache(key = 5)] - pub detail: String, - #[cultcache(key = 6)] - pub observed_at: String, -} - #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "muninn.capture_stream", schema = "muninn.capture_stream.v1")] pub struct MuninnCaptureStreamRecord { @@ -1800,37 +859,6 @@ cultmesh_rs::cultmesh_documents!(OdinDocuments { EveInterfaceBindingCompatRecord => EVE_INTERFACE_BINDING_SCHEMA, EveProviderAdvertisementRecord => EVE_PROVIDER_ADVERTISEMENT_SCHEMA, VoidBotSwarmStateSnapshotCompatRecord => VOIDBOT_SWARM_STATE_SNAPSHOT_SCHEMA, - IdunnDesiredDaemonRecord => IDUNN_DESIRED_DAEMON_SCHEMA, - IdunnDaemonHealthRecord => IDUNN_DAEMON_HEALTH_SCHEMA, - IdunnSignedHealthAdmissionRecord => IDUNN_SIGNED_HEALTH_ADMISSION_SCHEMA, - IdunnSignedDaemonHealthRecord => IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA, - IdunnDaemonHealthTrustBindingRecord => IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA, - IdunnAuthenticatedDaemonHealthAdmissionRecord => IDUNN_AUTHENTICATED_DAEMON_HEALTH_ADMISSION_SCHEMA, - IdunnRuntimeTrafficAdmissionRecord => IDUNN_RUNTIME_TRAFFIC_ADMISSION_SCHEMA, - IdunnUnsignedDaemonHealthDiagnosticRecord => IDUNN_UNSIGNED_DAEMON_HEALTH_DIAGNOSTIC_SCHEMA, - GameCultServiceTrustAnchorRecord => GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA, - IdunnAuthenticatedProviderHealthProjectionRecord => IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA, - IdunnKeepaliveDecisionRecord => IDUNN_KEEPALIVE_DECISION_SCHEMA, - IdunnRestartRequestRecord => IDUNN_RESTART_REQUEST_SCHEMA, - IdunnRestartResultRecord => IDUNN_RESTART_RESULT_SCHEMA, - IdunnDeploymentRequestRecord => IDUNN_DEPLOYMENT_REQUEST_SCHEMA, - IdunnCurrentDeploymentRequestRecord => IDUNN_CURRENT_DEPLOYMENT_REQUEST_SCHEMA, - IdunnDeploymentResultRecord => IDUNN_DEPLOYMENT_RESULT_SCHEMA, - IdunnLifecycleCommandRecord => IDUNN_LIFECYCLE_COMMAND_SCHEMA, - IdunnReleaseTargetRecord => IDUNN_RELEASE_TARGET_SCHEMA, - IdunnDeploymentArtifactRecord => IDUNN_DEPLOYMENT_ARTIFACT_SCHEMA, - BifrostRepositoryReleaseAuthorityRecord => BIFROST_REPOSITORY_RELEASE_AUTHORITY_SCHEMA, - IdunnStateMigrationPlanRecord => IDUNN_STATE_MIGRATION_PLAN_SCHEMA, - IdunnStateMigrationResultRecord => IDUNN_STATE_MIGRATION_RESULT_SCHEMA, - IdunnRolloutPlanRecord => IDUNN_ROLLOUT_PLAN_SCHEMA, - IdunnRolloutResultRecord => IDUNN_ROLLOUT_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, - IdunnRuntimeTransportCheckRecord => IDUNN_RUNTIME_TRANSPORT_CHECK_SCHEMA, - IdunnRudpHealthIngressRecord => IDUNN_RUDP_HEALTH_INGRESS_SCHEMA, MuninnTelemetrySurfaceRecord => MUNINN_TELEMETRY_SURFACE_SCHEMA, MuninnCaptureStreamRecord => MUNINN_CAPTURE_STREAM_SCHEMA, MuninnCaptureStreamCommandRecord => MUNINN_CAPTURE_STREAM_COMMAND_SCHEMA, @@ -1869,421 +897,6 @@ mod tests { use anyhow::Result; use cultmesh_rs::{CultMesh, CultMeshNodeOptions}; - fn signed_health_fixture() -> IdunnSignedDaemonHealthRecord { - IdunnSignedDaemonHealthRecord { - schema_version: IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA.into(), - daemon_id: "yggdrasil-epiphany".into(), - health_contract: "epiphany.cultnet-rudp-runtime-health".into(), - source_runtime_id: "epiphany-yggdrasil".into(), - state: "active".into(), - detail: "provider-authenticated resident readiness".into(), - signer_identity_id: "epiphany-host-identity".into(), - publisher_incarnation_id: "epiphany-yggdrasil/boot-7/process-11".into(), - publisher_sequence: 42, - observed_at_unix_millis: 1_784_483_200_000, - release_id: Some("release-42".into()), - release_witness_sha256: Some(format!("sha256-{}", "a".repeat(64))), - source_commit: Some("b".repeat(40)), - deployment_id: Some("deploy:yggdrasil-epiphany:42".into()), - signature_algorithm: "ed25519".into(), - signature: vec![9; 64], - private_state_exposed: false, - activation_witness_sha256: Some(format!("sha256-{}", "f".repeat(64))), - } - } - - fn trust_binding_fixture() -> IdunnDaemonHealthTrustBindingRecord { - let health = signed_health_fixture(); - let signer_public_key = vec![7; 32]; - IdunnDaemonHealthTrustBindingRecord { - schema_version: IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA.into(), - binding_id: "root/yggdrasil-epiphany/health".into(), - daemon_id: health.daemon_id, - health_contract: health.health_contract, - source_runtime_id: health.source_runtime_id, - signer_identity_id: derive_service_identity_id::( - &signer_public_key, - ) - .unwrap(), - signer_public_key, - binding_authority: "root".into(), - bound_at_unix_millis: 1_784_483_100_000, - release_binding_required: true, - private_state_exposed: false, - } - } - - fn authenticated_admission_fixture() -> IdunnAuthenticatedDaemonHealthAdmissionRecord { - let health = signed_health_fixture(); - let binding = trust_binding_fixture(); - IdunnAuthenticatedDaemonHealthAdmissionRecord { - schema_version: IDUNN_AUTHENTICATED_DAEMON_HEALTH_ADMISSION_SCHEMA.into(), - daemon_id: health.daemon_id, - health_contract: health.health_contract, - source_runtime_id: health.source_runtime_id, - state: health.state, - observed_at_unix_millis: health.observed_at_unix_millis, - admitted_at_unix_millis: health.observed_at_unix_millis + 1, - trust_binding_id: binding.binding_id, - trust_binding_sha256: format!("sha256-{}", "c".repeat(64)), - signer_identity_id: health.signer_identity_id, - publisher_incarnation_id: health.publisher_incarnation_id, - publisher_sequence: health.publisher_sequence, - signed_health_sha256: format!("sha256-{}", "d".repeat(64)), - release_id: health.release_id, - release_witness_sha256: health.release_witness_sha256, - source_commit: health.source_commit, - deployment_id: health.deployment_id, - private_state_exposed: false, - activation_witness_sha256: health.activation_witness_sha256, - } - } - - fn runtime_traffic_admission_fixture() -> IdunnRuntimeTrafficAdmissionRecord { - let admission = authenticated_admission_fixture(); - IdunnRuntimeTrafficAdmissionRecord { - schema_version: IDUNN_RUNTIME_TRAFFIC_ADMISSION_SCHEMA.into(), - daemon_id: IDUNN_CODEX_CONNECTOR_RUNTIME_TRAFFIC_ADMISSION_RECORD_KEY.into(), - release_id: admission.release_id.unwrap(), - release_witness_sha256: admission.release_witness_sha256.unwrap(), - source_commit: admission.source_commit.unwrap(), - deployment_id: admission.deployment_id.unwrap(), - activation_witness_sha256: admission.activation_witness_sha256.unwrap(), - signed_health_sha256: admission.signed_health_sha256, - publisher_incarnation_id: admission.publisher_incarnation_id, - publisher_sequence: admission.publisher_sequence, - signer_identity_id: admission.signer_identity_id, - runtime_process_id: 41, - runtime_process_starttime_ticks: 987_654, - } - } - - fn service_trust_anchor_fixture() -> GameCultServiceTrustAnchorRecord { - let signer_public_key = vec![5; 32]; - GameCultServiceTrustAnchorRecord { - schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), - trust_anchor_id: "root/idunn/authenticated-provider-health-projection".into(), - service_id: "idunn".into(), - runtime_id: "idunn-yggdrasil".into(), - signer_identity_id: derive_service_identity_id::( - &signer_public_key, - ) - .unwrap(), - signer_public_key, - signature_algorithm: "ed25519".into(), - signing_purpose: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE.into(), - signed_schema: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA.into(), - binding_authority: "root".into(), - bound_at_unix_millis: 1_784_483_100_000, - expires_at_unix_millis: Some(1_815_000_000_000), - private_state_exposed: false, - } - } - - fn authenticated_provider_health_projection_fixture() - -> IdunnAuthenticatedProviderHealthProjectionRecord { - let health = signed_health_fixture(); - let binding = trust_binding_fixture(); - IdunnAuthenticatedProviderHealthProjectionRecord { - schema_version: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA.into(), - projection_id: format!("authenticated-provider-health:{}", health.daemon_id), - daemon_id: health.daemon_id, - health_contract: health.health_contract, - provider_state: health.state, - reason_code: IDUNN_PROVIDER_ACTIVE_REASON.into(), - provider_observed_at_unix_millis: health.observed_at_unix_millis, - admitted_at_unix_millis: health.observed_at_unix_millis + 1, - evaluated_at_unix_millis: health.observed_at_unix_millis + 2, - trust_binding_id: binding.binding_id, - trust_binding_sha256: format!("sha256-{}", "c".repeat(64)), - signed_health_sha256: format!("sha256-{}", "d".repeat(64)), - authenticated_admission_sha256: format!("sha256-{}", "e".repeat(64)), - provider_signer_identity_id: health.signer_identity_id, - provider_incarnation_id: health.publisher_incarnation_id, - provider_sequence: health.publisher_sequence, - release_id: health.release_id, - release_witness_sha256: health.release_witness_sha256, - source_commit: health.source_commit, - deployment_id: health.deployment_id, - idunn_runtime_id: "idunn-yggdrasil".into(), - idunn_signer_identity_id: "idunn-yggdrasil-service-identity".into(), - projection_incarnation_id: "idunn-yggdrasil/boot-3/process-8".into(), - projection_sequence: 7, - signature_algorithm: "ed25519".into(), - signature: vec![6; 64], - private_state_exposed: false, - expires_at_unix_millis: health.observed_at_unix_millis + 60_002, - } - } - - #[test] - fn outward_authenticated_provider_health_contracts_validate_and_round_trip() -> Result<()> { - let anchor = service_trust_anchor_fixture(); - let projection = authenticated_provider_health_projection_fixture(); - anchor.validate()?; - projection.validate()?; - - let temp = tempfile::tempdir()?; - let store_path = temp - .path() - .join("authenticated-provider-health-projection.cc"); - let mut node = CultMesh::create_node( - &store_path, - OdinDocuments, - CultMeshNodeOptions { - runtime_id: "authenticated-provider-health-contract-test".into(), - pull_on_start: true, - }, - )?; - node.put(&anchor.trust_anchor_id, &anchor)?; - node.put(&projection.projection_id, &projection)?; - assert_eq!( - node.get_required::(&anchor.trust_anchor_id)?, - anchor - ); - assert_eq!( - node.get_required::( - &projection.projection_id - )?, - projection - ); - assert_eq!( - authenticated_provider_health_reason_code("warming"), - Some(IDUNN_PROVIDER_WARMING_REASON) - ); - assert_eq!( - authenticated_provider_health_reason_code("degraded"), - Some(IDUNN_PROVIDER_DEGRADED_REASON) - ); - assert_eq!( - authenticated_provider_health_reason_code("failed"), - Some(IDUNN_PROVIDER_FAILED_REASON) - ); - assert_eq!(authenticated_provider_health_reason_code("missing"), None); - Ok(()) - } - - #[test] - fn outward_authenticated_provider_health_contracts_refuse_substitution_and_weak_lineage() { - let mut anchor = service_trust_anchor_fixture(); - anchor.binding_authority = "idunn".into(); - assert!(anchor.validate().is_err()); - anchor = service_trust_anchor_fixture(); - anchor.signer_public_key = vec![5; 31]; - assert!(anchor.validate().is_err()); - anchor = service_trust_anchor_fixture(); - anchor.expires_at_unix_millis = Some(anchor.bound_at_unix_millis); - assert!(anchor.validate().is_err()); - anchor = service_trust_anchor_fixture(); - anchor.private_state_exposed = true; - assert!(anchor.validate().is_err()); - anchor = service_trust_anchor_fixture(); - anchor.signing_purpose = "caller.chosen-purpose".into(); - assert!(anchor.validate().is_err()); - anchor = service_trust_anchor_fixture(); - anchor.signer_identity_id = "caller-chosen-identity".into(); - assert!(anchor.validate().is_err()); - - let mut projection = authenticated_provider_health_projection_fixture(); - projection.evaluated_at_unix_millis = projection.admitted_at_unix_millis - 1; - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.expires_at_unix_millis = projection.evaluated_at_unix_millis; - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.authenticated_admission_sha256 = "admitted".into(); - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.projection_sequence = 0; - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.signature.clear(); - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.release_witness_sha256 = None; - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.deployment_id = None; - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.private_state_exposed = true; - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.reason_code = "release_drift".into(); - assert!(projection.validate().is_err()); - projection = authenticated_provider_health_projection_fixture(); - projection.provider_state = "missing".into(); - projection.reason_code = "authenticated_provider_failed".into(); - assert!(projection.validate().is_err()); - } - - #[test] - fn generic_signed_health_contracts_validate_and_round_trip() -> Result<()> { - let health = signed_health_fixture(); - let binding = trust_binding_fixture(); - let admission = authenticated_admission_fixture(); - health.validate()?; - binding.validate()?; - admission.validate()?; - - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("signed-health.cc"); - let mut node = CultMesh::create_node( - &store_path, - OdinDocuments, - CultMeshNodeOptions { - runtime_id: "idunn-test".into(), - pull_on_start: true, - }, - )?; - node.put(&health.daemon_id, &health)?; - node.put(&binding.binding_id, &binding)?; - node.put(&admission.daemon_id, &admission)?; - assert_eq!( - node.get_required::(&health.daemon_id)?, - health - ); - assert_eq!( - node.get_required::(&binding.binding_id)?, - binding - ); - assert_eq!( - node.get_required::( - &admission.daemon_id - )?, - admission - ); - Ok(()) - } - - #[test] - fn runtime_traffic_admission_is_a_closed_positional_document() -> Result<()> { - let admission = runtime_traffic_admission_fixture(); - admission.validate()?; - assert_eq!( - IdunnRuntimeTrafficAdmissionRecord::TYPE, - "idunn.runtime_traffic_admission" - ); - assert_eq!( - IdunnRuntimeTrafficAdmissionRecord::SCHEMA_NAME, - IDUNN_RUNTIME_TRAFFIC_ADMISSION_SCHEMA - ); - let payload = rmp_serde::to_vec(&admission)?; - assert_eq!(payload.first(), Some(&0x9d)); - assert_eq!( - rmp_serde::from_slice::(&payload)?, - admission - ); - let pre_process_binding = rmp_serde::to_vec(&( - admission.schema_version.clone(), - admission.daemon_id.clone(), - admission.release_id.clone(), - admission.release_witness_sha256.clone(), - admission.source_commit.clone(), - admission.deployment_id.clone(), - admission.activation_witness_sha256.clone(), - admission.signed_health_sha256.clone(), - admission.publisher_incarnation_id.clone(), - admission.publisher_sequence, - admission.signer_identity_id.clone(), - ))?; - assert_eq!(pre_process_binding.first(), Some(&0x9b)); - assert!( - rmp_serde::from_slice::(&pre_process_binding) - .is_err(), - "runtime admission v2 must not decode a pre-process-binding body" - ); - let mut ghostlight = admission.clone(); - ghostlight.daemon_id = IDUNN_GHOSTLIGHT_RUNTIME_TRAFFIC_ADMISSION_RECORD_KEY.into(); - ghostlight.validate()?; - Ok(()) - } - - #[test] - fn runtime_traffic_admission_refuses_weak_or_partial_lineage() { - let mut admission = runtime_traffic_admission_fixture(); - admission.publisher_sequence = 0; - assert!(admission.validate().is_err()); - - admission = runtime_traffic_admission_fixture(); - admission.source_commit = "F".repeat(40); - assert!(admission.validate().is_err()); - - admission = runtime_traffic_admission_fixture(); - admission.activation_witness_sha256 = "missing".into(); - assert!(admission.validate().is_err()); - - admission = runtime_traffic_admission_fixture(); - admission.schema_version = "idunn.runtime_traffic_admission.v0".into(); - assert!(admission.validate().is_err()); - - admission = runtime_traffic_admission_fixture(); - admission.daemon_id = "caller-selected-daemon".into(); - assert!(admission.validate().is_err()); - - admission = runtime_traffic_admission_fixture(); - admission.runtime_process_id = 0; - assert!(admission.validate().is_err()); - - admission = runtime_traffic_admission_fixture(); - admission.runtime_process_starttime_ticks = 0; - assert!(admission.validate().is_err()); - } - - #[test] - fn activation_binding_is_additive_for_existing_authenticated_admissions() -> Result<()> { - let mut admission = authenticated_admission_fixture(); - admission.activation_witness_sha256 = None; - let mut legacy = rmp_serde::to_vec(&admission)?; - assert_eq!(&legacy[..3], &[0xdc, 0, 19]); - assert_eq!(legacy.pop(), Some(0xc0)); - legacy[2] = 18; - let decoded: IdunnAuthenticatedDaemonHealthAdmissionRecord = - rmp_serde::from_slice(&legacy)?; - assert_eq!(decoded.activation_witness_sha256, None); - decoded.validate()?; - Ok(()) - } - - #[test] - fn generic_signed_health_contracts_refuse_weak_or_partial_authority() { - let mut health = signed_health_fixture(); - health.private_state_exposed = true; - assert!(health.validate().is_err()); - health = signed_health_fixture(); - health.signature.clear(); - assert!(health.validate().is_err()); - health = signed_health_fixture(); - health.release_witness_sha256 = None; - assert!(health.validate().is_err()); - health = signed_health_fixture(); - health.publisher_sequence = 0; - assert!(health.validate().is_err()); - health = signed_health_fixture(); - health.activation_witness_sha256 = Some(format!("sha256-{}", "F".repeat(64))); - assert!(health.validate().is_err()); - - let mut binding = trust_binding_fixture(); - binding.binding_authority = "daemon".into(); - assert!(binding.validate().is_err()); - binding = trust_binding_fixture(); - binding.signer_public_key = vec![1; 31]; - assert!(binding.validate().is_err()); - binding = trust_binding_fixture(); - binding.private_state_exposed = true; - assert!(binding.validate().is_err()); - - let mut admission = authenticated_admission_fixture(); - admission.trust_binding_sha256 = "ready".into(); - assert!(admission.validate().is_err()); - admission = authenticated_admission_fixture(); - admission.admitted_at_unix_millis = admission.observed_at_unix_millis - 1; - assert!(admission.validate().is_err()); - admission = authenticated_admission_fixture(); - admission.activation_witness_sha256 = Some("caller-selected".into()); - assert!(admission.validate().is_err()); - } - #[test] fn muninn_media_documents_round_trip_through_cultmesh() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/crates/odin-core/src/idunn.rs b/crates/odin-core/src/idunn.rs deleted file mode 100644 index a699c1d..0000000 --- a/crates/odin-core/src/idunn.rs +++ /dev/null @@ -1,672 +0,0 @@ -use crate::documents::{ - IdunnDaemonHealthRecord, IdunnDeploymentRequestRecord, IdunnDesiredDaemonRecord, - IdunnKeepaliveDecisionRecord, IdunnOperatorAlarmRecord, IdunnRestartRequestRecord, -}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct IdunnPlan { - pub decision: IdunnKeepaliveDecisionRecord, - pub deployment_request: Option, - pub restart_request: Option, - pub operator_alarm: Option, -} - -pub fn plan_keepalive( - desired: &IdunnDesiredDaemonRecord, - health: &IdunnDaemonHealthRecord, - now: impl Into, -) -> IdunnPlan { - let now = now.into(); - let decision_id = format!("{}:{}", desired.daemon_id, now); - - if !desired.enabled { - return IdunnPlan { - decision: decision(&decision_id, desired, "noop", "daemon is disabled", &now), - deployment_request: None, - restart_request: None, - operator_alarm: None, - }; - } - - if is_healthy(&health.state) { - return IdunnPlan { - decision: decision(&decision_id, desired, "observe", "daemon is healthy", &now), - deployment_request: None, - restart_request: None, - operator_alarm: None, - }; - } - - if health.state == "warming" { - return IdunnPlan { - decision: decision( - &decision_id, - desired, - "observe", - "daemon is warming under provider-owned progress authority", - &now, - ), - deployment_request: None, - restart_request: None, - operator_alarm: None, - }; - } - - if health.state == "degraded" || health.state == "dependency-unavailable" { - let alarm_id = format!("alarm:{}:{}", desired.daemon_id, now); - let reason = format!( - "health is {}; local restart/deploy is not the owner of this failure: {}", - health.state, health.detail - ); - return IdunnPlan { - decision: decision(&decision_id, desired, "alarm", &reason, &now), - deployment_request: None, - restart_request: None, - operator_alarm: Some(IdunnOperatorAlarmRecord { - alarm_id, - daemon_id: desired.daemon_id.clone(), - severity: "operator-action-required".to_string(), - reason, - escalation_target: "bifrost.operator-notification".to_string(), - raised_at: now, - }), - }; - } - - if let Some(command) = desired.deploy_command.as_deref() { - if !command.trim().is_empty() && health.state == "stale-deployment" { - let request_id = format!("deploy:{}:{}", desired.daemon_id, now); - return IdunnPlan { - decision: decision( - &decision_id, - desired, - "deploy", - &format!( - "health is {}; deployment authority is available", - health.state - ), - &now, - ), - deployment_request: Some(IdunnDeploymentRequestRecord { - request_id, - daemon_id: desired.daemon_id.clone(), - command: command.to_string(), - authority: desired.authority.clone(), - requested_at: now, - repository_full_name: String::new(), - upstream_ref: String::new(), - source_revision: String::new(), - release_authority_id: String::new(), - release_authority_envelope_sha256: String::new(), - requires_bifrost_authority: false, - }), - restart_request: None, - operator_alarm: None, - }; - } - } - - if health.state == "stale-deployment" { - let alarm_id = format!("alarm:{}:{}", desired.daemon_id, now); - let reason = format!( - "health is stale-deployment under {}; no deploy command authority is available", - desired.health_contract - ); - return IdunnPlan { - decision: decision(&decision_id, desired, "alarm", &reason, &now), - deployment_request: None, - restart_request: None, - operator_alarm: Some(IdunnOperatorAlarmRecord { - alarm_id, - daemon_id: desired.daemon_id.clone(), - severity: "operator-action-required".to_string(), - reason, - escalation_target: "bifrost.operator-notification".to_string(), - raised_at: now, - }), - }; - } - - match desired.restart_command.as_deref() { - Some(command) if !command.trim().is_empty() => { - let request_id = format!("restart:{}:{}", desired.daemon_id, now); - IdunnPlan { - decision: decision( - &decision_id, - desired, - "restart", - &format!("health is {}; restart authority is available", health.state), - &now, - ), - restart_request: Some(IdunnRestartRequestRecord { - request_id, - daemon_id: desired.daemon_id.clone(), - command: command.to_string(), - authority: desired.authority.clone(), - requested_at: now, - }), - deployment_request: None, - operator_alarm: None, - } - } - _ => { - let alarm_id = format!("alarm:{}:{}", desired.daemon_id, now); - let reason = format!( - "health is {}; no restart command authority is available", - health.state - ); - IdunnPlan { - decision: decision(&decision_id, desired, "alarm", &reason, &now), - deployment_request: None, - restart_request: None, - operator_alarm: Some(IdunnOperatorAlarmRecord { - alarm_id, - daemon_id: desired.daemon_id.clone(), - severity: "operator-action-required".to_string(), - reason, - escalation_target: "bifrost.operator-notification".to_string(), - raised_at: now, - }), - } - } - } -} - -fn is_healthy(state: &str) -> bool { - matches!(state, "active" | "healthy" | "ok" | "running") -} - -fn decision( - decision_id: &str, - desired: &IdunnDesiredDaemonRecord, - action: &str, - reason: &str, - decided_at: &str, -) -> IdunnKeepaliveDecisionRecord { - IdunnKeepaliveDecisionRecord { - decision_id: decision_id.to_string(), - daemon_id: desired.daemon_id.clone(), - action: action.to_string(), - reason: reason.to_string(), - authority: desired.authority.clone(), - decided_at: decided_at.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::documents::{ - IDUNN_COMMAND_BOUNDARY_SCHEMA, IDUNN_DAEMON_SURGERY_PLAN_SCHEMA, - IDUNN_DAEMON_TRANSPORT_PROFILE_SCHEMA, IDUNN_DEPLOYMENT_ARTIFACT_SCHEMA, - IDUNN_DESIRED_DAEMON_SCHEMA, IDUNN_RELEASE_TARGET_SCHEMA, IDUNN_ROLLOUT_PLAN_SCHEMA, - IDUNN_STATE_MIGRATION_PLAN_SCHEMA, IDUNN_SWARM_SURGERY_PLAN_SCHEMA, - IdunnCommandBoundaryRecord, IdunnDaemonSurgeryPlanRecord, - IdunnDaemonTransportProfileRecord, IdunnDeploymentArtifactRecord, IdunnReleaseTargetRecord, - IdunnRolloutPlanRecord, IdunnRuntimeTransportCheckRecord, IdunnStateMigrationPlanRecord, - IdunnSwarmSurgeryPlanRecord, OdinDocuments, - }; - use anyhow::Result; - use cultmesh_rs::{CultMesh, CultMeshNodeOptions}; - - fn desired(restart_command: Option<&str>) -> IdunnDesiredDaemonRecord { - IdunnDesiredDaemonRecord { - daemon_id: "voidbot".to_string(), - verse_id: "local".to_string(), - name: "VoidBot".to_string(), - enabled: true, - health_command: None, - restart_command: restart_command.map(ToString::to_string), - authority: "idunn-supervisor-command".to_string(), - max_silence_seconds: 60, - observed_at: "2026-06-04T00:00:00Z".to_string(), - deploy_command: None, - health_contract: "test.cultnet-rudp-health".to_string(), - transport_profile_id: "transport:voidbot".to_string(), - command_boundary_id: "command-boundary:voidbot".to_string(), - } - } - - fn health(state: &str) -> IdunnDaemonHealthRecord { - IdunnDaemonHealthRecord { - daemon_id: "voidbot".to_string(), - state: state.to_string(), - detail: "unit daemon publication".to_string(), - observed_at: "2026-06-04T00:00:01Z".to_string(), - health_contract: "test.cultnet-rudp-health".to_string(), - publication_source: "debug-command".to_string(), - transport: "debug.local-command".to_string(), - } - } - - #[test] - fn healthy_daemon_is_observed_without_restart() { - let plan = plan_keepalive( - &desired(Some("npm start")), - &health("active"), - "2026-06-04T00:00:02Z", - ); - - assert_eq!(plan.decision.action, "observe"); - assert!(plan.deployment_request.is_none()); - assert!(plan.restart_request.is_none()); - assert!(plan.operator_alarm.is_none()); - } - - #[test] - fn warming_daemon_is_observed_without_actuation_or_promotion() { - let mut desired = desired(Some("systemctl restart epiphany")); - desired.deploy_command = Some("deploy epiphany".to_string()); - let plan = plan_keepalive(&desired, &health("warming"), "2026-07-17T00:00:02Z"); - - assert_eq!(plan.decision.action, "observe"); - assert!(plan.decision.reason.contains("provider-owned progress")); - assert!(plan.deployment_request.is_none()); - assert!(plan.restart_request.is_none()); - assert!(plan.operator_alarm.is_none()); - } - - #[test] - fn unhealthy_daemon_with_restart_authority_requests_restart() { - let plan = plan_keepalive( - &desired(Some("npm start")), - &health("failed"), - "2026-06-04T00:00:02Z", - ); - - assert_eq!(plan.decision.action, "restart"); - assert!(plan.deployment_request.is_none()); - assert_eq!(plan.restart_request.unwrap().command, "npm start"); - assert!(plan.operator_alarm.is_none()); - } - - #[test] - fn unhealthy_daemon_with_deploy_authority_requests_deployment_before_restart() { - let mut desired = desired(Some("systemctl restart gjallar")); - desired.deploy_command = Some("deploy gjallar".to_string()); - let plan = plan_keepalive( - &desired, - &health("stale-deployment"), - "2026-06-04T00:00:02Z", - ); - - assert_eq!(plan.decision.action, "deploy"); - assert_eq!(plan.deployment_request.unwrap().command, "deploy gjallar"); - assert!(plan.restart_request.is_none()); - assert!(plan.operator_alarm.is_none()); - } - - #[test] - fn failed_daemon_with_deploy_and_restart_authority_restarts_instead_of_redeploying() { - let mut desired = desired(Some("systemctl restart gjallar")); - desired.deploy_command = Some("deploy gjallar".to_string()); - let plan = plan_keepalive(&desired, &health("failed"), "2026-06-04T00:00:02Z"); - - assert_eq!(plan.decision.action, "restart"); - assert!(plan.deployment_request.is_none()); - assert_eq!( - plan.restart_request.unwrap().command, - "systemctl restart gjallar" - ); - assert!(plan.operator_alarm.is_none()); - } - - #[test] - fn dependency_unavailable_raises_alarm_instead_of_redeploying_or_restarting() { - let mut desired = desired(Some("systemctl restart gjallar")); - desired.deploy_command = Some("deploy gjallar".to_string()); - let plan = plan_keepalive( - &desired, - &health("dependency-unavailable"), - "2026-06-04T00:00:02Z", - ); - - assert_eq!(plan.decision.action, "alarm"); - assert!(plan.deployment_request.is_none()); - assert!(plan.restart_request.is_none()); - assert!(plan.operator_alarm.is_some()); - } - - #[test] - fn stale_deployment_without_deploy_authority_raises_alarm_instead_of_restarting() { - let plan = plan_keepalive( - &desired(Some("systemctl restart old-app")), - &health("stale-deployment"), - "2026-06-04T00:00:02Z", - ); - - assert_eq!(plan.decision.action, "alarm"); - assert!(plan.deployment_request.is_none()); - assert!(plan.restart_request.is_none()); - assert!(plan.operator_alarm.is_some()); - } - - #[test] - fn unhealthy_daemon_without_authority_raises_operator_alarm() { - let plan = plan_keepalive(&desired(None), &health("failed"), "2026-06-04T00:00:02Z"); - - assert_eq!(plan.decision.action, "alarm"); - assert!(plan.deployment_request.is_none()); - assert!(plan.restart_request.is_none()); - assert_eq!( - plan.operator_alarm.unwrap().escalation_target, - "bifrost.operator-notification" - ); - } - - #[test] - fn idunn_records_round_trip_through_cultmesh() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("idunn.cc"); - let desired = desired(Some("npm start")); - let health = health("failed"); - let plan = plan_keepalive(&desired, &health, "2026-06-04T00:00:02Z"); - let request = plan.restart_request.unwrap(); - - let mut node = CultMesh::create_node( - &store_path, - OdinDocuments, - CultMeshNodeOptions { - runtime_id: "idunn-test".to_string(), - pull_on_start: true, - }, - )?; - node.put(&desired.daemon_id, &desired)?; - node.put(&health.daemon_id, &health)?; - node.put(&plan.decision.decision_id, &plan.decision)?; - node.put(&request.request_id, &request)?; - let surgery_plan = IdunnDaemonSurgeryPlanRecord { - plan_id: "surgery:voidbot".to_string(), - daemon_id: "voidbot".to_string(), - severity: "high".to_string(), - status: "transport-surgery-required".to_string(), - owner: "VoidBot internal provider stack".to_string(), - objective: "Publish VoidBot internal daemon health over CultNet/RUDP".to_string(), - current_mechanism: "daemon-published RUDP health".to_string(), - intended_authority: "daemon-owned CultNet/RUDP health record".to_string(), - cut_line: "daemon health must be published over CultNet/RUDP".to_string(), - steps: vec![ - "update CultLib".to_string(), - "publish RUDP health".to_string(), - ], - 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: "daemon-published RUDP health required".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: "daemon health must be published over CultNet/RUDP".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(), - daemon_id: "voidbot".to_string(), - target_transport: "cultnet.transport.rudp.v0".to_string(), - current_transport: "missing-daemon-published-rudp-health".to_string(), - state: "migration-required".to_string(), - health_contract: "test.cultnet-rudp-health".to_string(), - publication_schema: "idunn.daemon_health.v1".to_string(), - debug_mechanism: "none".to_string(), - cut_line: "daemon health must be published over CultNet/RUDP".to_string(), - observed_at: "2026-06-04T00:00:02Z".to_string(), - }; - node.put(&transport_profile.profile_id, &transport_profile)?; - let command_boundary = IdunnCommandBoundaryRecord { - boundary_id: "command-boundary:voidbot".to_string(), - daemon_id: "voidbot".to_string(), - owner: "idunn-supervisor-command-boundary".to_string(), - restart_authority: "idunn-supervisor-command.restart".to_string(), - deploy_authority: "none".to_string(), - health_authority: "daemon-published-rudp-health".to_string(), - alarm_authority: "bifrost.operator-notification".to_string(), - command_lowerings: vec!["npm start".to_string()], - forbidden_authority: "debug probes do not own truth".to_string(), - observed_at: "2026-06-04T00:00:02Z".to_string(), - }; - node.put(&command_boundary.boundary_id, &command_boundary)?; - let release_target = IdunnReleaseTargetRecord { - target_id: "release-target:voidbot".to_string(), - daemon_id: "voidbot".to_string(), - repo: "VoidBot".to_string(), - repo_path: "E:\\Projects\\VoidBot".to_string(), - upstream_remote: "origin".to_string(), - upstream_branch: "main".to_string(), - desired_revision: "abc123".to_string(), - deployed_revision: "old123".to_string(), - artifact_strategy: "source-archive-from-upstream-main".to_string(), - rollout_strategy: "restart-after-verified-build".to_string(), - state_migration_authority: "daemon-owned-command".to_string(), - zero_downtime_capability: "restart-required".to_string(), - status: "tracked".to_string(), - observed_at: "2026-06-04T00:00:02Z".to_string(), - repository_full_name: "GameCult/VoidBot".to_string(), - upstream_ref: "refs/heads/main".to_string(), - release_authority_id: "release:GameCult/VoidBot:refs/heads/main:abc123".to_string(), - release_authority_envelope_sha256: "authority-sha".to_string(), - release_authority_status: "authorized".to_string(), - requires_bifrost_authority: true, - observed_upstream_revision: "abc123".to_string(), - minimum_source_revision: None, - }; - let artifact = IdunnDeploymentArtifactRecord { - artifact_id: "artifact:voidbot:main".to_string(), - daemon_id: "voidbot".to_string(), - source_revision: "abc123".to_string(), - source_branch: "main".to_string(), - source_remote: "origin".to_string(), - artifact_kind: "source-archive-from-upstream-main".to_string(), - artifact_uri: "built-by-deploy-command".to_string(), - sha256: "pending-deploy-command".to_string(), - built_at: "2026-06-04T00:00:02Z".to_string(), - release_authority_id: release_target.release_authority_id.clone(), - release_authority_envelope_sha256: release_target - .release_authority_envelope_sha256 - .clone(), - }; - let migration_plan = IdunnStateMigrationPlanRecord { - plan_id: "migration:voidbot:main".to_string(), - daemon_id: "voidbot".to_string(), - from_schema_version: "deployed-state".to_string(), - to_schema_version: "target-revision-state".to_string(), - authority: "daemon-owned-migrator".to_string(), - command: "voidbot migrate-state".to_string(), - strategy: "backup-then-daemon-migrator".to_string(), - backup_required: true, - zero_downtime_required: false, - status: "planned".to_string(), - planned_at: "2026-06-04T00:00:02Z".to_string(), - }; - let rollout_plan = IdunnRolloutPlanRecord { - plan_id: "rollout:voidbot:main".to_string(), - daemon_id: "voidbot".to_string(), - desired_revision: "abc123".to_string(), - deployed_revision: "old123".to_string(), - strategy: "restart-after-verified-build".to_string(), - phases: vec![ - "fetch upstream main".to_string(), - "verify health".to_string(), - ], - migration_plan_id: migration_plan.plan_id.clone(), - artifact_id: artifact.artifact_id.clone(), - status: "planned".to_string(), - planned_at: "2026-06-04T00:00:02Z".to_string(), - }; - let release_payload = rmp_serde::to_vec(&release_target)?; - assert_eq!(&release_payload[..3], &[0xdc, 0, 22]); - let mut pre_floor_payload = release_payload.clone(); - assert_eq!(pre_floor_payload.pop(), Some(0xc0)); - pre_floor_payload[2] = 21; - assert!( - rmp_serde::from_slice::(&pre_floor_payload).is_err(), - "release-target v3 must not decode a pre-floor positional body" - ); - node.put(&release_target.target_id, &release_target)?; - node.put(&artifact.artifact_id, &artifact)?; - node.put(&migration_plan.plan_id, &migration_plan)?; - node.put(&rollout_plan.plan_id, &rollout_plan)?; - let runtime_check = IdunnRuntimeTransportCheckRecord { - check_id: "idunn-runtime-rudp-loopback".to_string(), - runtime_id: "idunn-daemon".to_string(), - transport: "cultnet.transport.rudp.v0".to_string(), - state: "available".to_string(), - detail: "loopback acknowledged".to_string(), - observed_at: "2026-06-04T00:00:02Z".to_string(), - }; - node.put(&runtime_check.check_id, &runtime_check)?; - - let reloaded = CultMesh::create_node( - &store_path, - OdinDocuments, - CultMeshNodeOptions { - runtime_id: "idunn-test-reloaded".to_string(), - pull_on_start: true, - }, - )?; - - assert_eq!( - reloaded - .documents() - .binding("idunn.desired_daemon") - .and_then(|binding| binding.payload_schema_version.clone()) - .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() - .binding("idunn.daemon_surgery_plan") - .and_then(|binding| binding.payload_schema_version.clone()) - .as_deref(), - Some(IDUNN_DAEMON_SURGERY_PLAN_SCHEMA) - ); - assert_eq!( - reloaded - .documents() - .binding("idunn.daemon_transport_profile") - .and_then(|binding| binding.payload_schema_version.clone()) - .as_deref(), - Some(IDUNN_DAEMON_TRANSPORT_PROFILE_SCHEMA) - ); - assert_eq!( - reloaded - .documents() - .binding("idunn.command_boundary") - .and_then(|binding| binding.payload_schema_version.clone()) - .as_deref(), - Some(IDUNN_COMMAND_BOUNDARY_SCHEMA) - ); - assert_eq!( - reloaded - .documents() - .binding("idunn.release_target") - .and_then(|binding| binding.payload_schema_version.clone()) - .as_deref(), - Some(IDUNN_RELEASE_TARGET_SCHEMA) - ); - assert_eq!( - reloaded - .documents() - .binding("idunn.deployment_artifact") - .and_then(|binding| binding.payload_schema_version.clone()) - .as_deref(), - Some(IDUNN_DEPLOYMENT_ARTIFACT_SCHEMA) - ); - assert_eq!( - reloaded - .documents() - .binding("idunn.state_migration_plan") - .and_then(|binding| binding.payload_schema_version.clone()) - .as_deref(), - Some(IDUNN_STATE_MIGRATION_PLAN_SCHEMA) - ); - assert_eq!( - reloaded - .documents() - .binding("idunn.rollout_plan") - .and_then(|binding| binding.payload_schema_version.clone()) - .as_deref(), - Some(IDUNN_ROLLOUT_PLAN_SCHEMA) - ); - let reloaded_desired = - reloaded.get_required::(&desired.daemon_id)?; - assert_eq!(reloaded_desired.transport_profile_id, "transport:voidbot"); - assert_eq!( - reloaded_desired.command_boundary_id, - "command-boundary:voidbot" - ); - assert_eq!( - reloaded - .get_required::(&plan.decision.decision_id)? - .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)? - .cut_line, - "daemon health must be published over CultNet/RUDP" - ); - assert_eq!( - reloaded - .get_required::(&transport_profile.profile_id)? - .target_transport, - "cultnet.transport.rudp.v0" - ); - assert_eq!( - reloaded - .get_required::(&command_boundary.boundary_id)? - .health_authority, - "daemon-published-rudp-health" - ); - assert_eq!( - reloaded - .get_required::(&runtime_check.check_id)? - .state, - "available" - ); - assert_eq!( - reloaded - .get_required::(&release_target.target_id)? - .upstream_branch, - "main" - ); - assert_eq!( - reloaded - .get_required::(&rollout_plan.plan_id)? - .migration_plan_id, - "migration:voidbot:main" - ); - assert_eq!( - reloaded - .get_required::(&request.request_id)? - .command, - "npm start" - ); - Ok(()) - } -} diff --git a/crates/odin-core/src/lib.rs b/crates/odin-core/src/lib.rs index 5133002..3c7cb49 100644 --- a/crates/odin-core/src/lib.rs +++ b/crates/odin-core/src/lib.rs @@ -1,13 +1,11 @@ pub mod discovery; pub mod documents; -pub mod idunn; pub mod pipeline; pub mod ports; pub mod repository; pub use discovery::*; pub use documents::*; -pub use idunn::*; pub use pipeline::*; pub use ports::*; pub use repository::*; From 06282c47d819fd9f325b0d201b64dd1686d117ad Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 00:21:30 +0200 Subject: [PATCH 02/34] Delete the retired Node coordinator, its tests and npm entry points src/odin-coordinator.cjs and src/odin/* (probes.cjs docker/adb host probes, state.cjs, provider-ingress.cjs with its 120 s TTL) were retired when the Rust odin-daemon became the deployed body (state/map.yaml: the Node coordinator that held 17871 is retired). The recipe builds only odin-daemon; nothing in the Rust crate graph loads a .cjs file. The live Yggdrasil hermodr container runs from the immutable release /srv/odin/current, not from this tree. Removes src/, test/ (4 test files for that JS), scripts/check-syntax.cjs (the JS syntax gate), and the package.json scripts start, check, test, muninn:build, muninn:start (the last two named a crate that is not in the workspace). gjallar:persona and rust:test stay. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-syntax.cjs | 34 - src/odin-coordinator.cjs | 212 ---- src/odin/config.cjs | 73 -- src/odin/cultnet-rudp.cjs | 115 -- src/odin/documents.cjs | 26 - src/odin/idunn-rudp.cjs | 65 - src/odin/interfaces.cjs | 1179 ------------------ src/odin/layout.cjs | 279 ----- src/odin/marquee.cjs | 111 -- src/odin/probes.cjs | 48 - src/odin/provider-ingress.cjs | 278 ----- src/odin/state.cjs | 216 ---- src/odin/surface.cjs | 212 ---- test/ghostlight-discovery-documents.test.cjs | 21 - test/heimdall-discovery-documents.test.cjs | 23 - test/idunn-signed-health.test.cjs | 67 - test/provider-ingress.test.cjs | 29 - 17 files changed, 2988 deletions(-) delete mode 100644 scripts/check-syntax.cjs delete mode 100644 src/odin-coordinator.cjs delete mode 100644 src/odin/config.cjs delete mode 100644 src/odin/cultnet-rudp.cjs delete mode 100644 src/odin/documents.cjs delete mode 100644 src/odin/idunn-rudp.cjs delete mode 100644 src/odin/interfaces.cjs delete mode 100644 src/odin/layout.cjs delete mode 100644 src/odin/marquee.cjs delete mode 100644 src/odin/probes.cjs delete mode 100644 src/odin/provider-ingress.cjs delete mode 100644 src/odin/state.cjs delete mode 100644 src/odin/surface.cjs delete mode 100644 test/ghostlight-discovery-documents.test.cjs delete mode 100644 test/heimdall-discovery-documents.test.cjs delete mode 100644 test/idunn-signed-health.test.cjs delete mode 100644 test/provider-ingress.test.cjs diff --git a/scripts/check-syntax.cjs b/scripts/check-syntax.cjs deleted file mode 100644 index f08990b..0000000 --- a/scripts/check-syntax.cjs +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const { spawnSync } = require("child_process"); - -const repoRoot = path.resolve(__dirname, ".."); -const files = []; - -collect(path.join(repoRoot, "src")); - -for (const file of files) { - const result = spawnSync(process.execPath, ["--check", file], { - cwd: repoRoot, - encoding: "utf8", - }); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - if (result.status !== 0) process.exit(result.status); -} - -console.log(`Checked ${files.length} CommonJS files.`); - -function collect(directory) { - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const fullPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - collect(fullPath); - } else if (entry.isFile() && entry.name.endsWith(".cjs")) { - files.push(fullPath); - } - } -} diff --git a/src/odin-coordinator.cjs b/src/odin-coordinator.cjs deleted file mode 100644 index 66b4ec4..0000000 --- a/src/odin-coordinator.cjs +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("fs"); -const path = require("path"); - -const { buildConfig, loadCultRuntime } = require("./odin/config.cjs"); -const { createCultMeshRudpDocumentServer } = require("./odin/cultnet-rudp.cjs"); -const { defineOdinDocuments } = require("./odin/documents.cjs"); -const { createInterfaceDiscovery } = require("./odin/interfaces.cjs"); -const { createIdunnRudpHealthPublisher, publishIdunnRudpHealth } = require("./odin/idunn-rudp.cjs"); -const { createLayoutStore } = require("./odin/layout.cjs"); -const { createLiveProviderRegistry } = require("./odin/provider-ingress.cjs"); -const { createStateBuilder } = require("./odin/state.cjs"); - -const config = buildConfig(process.argv.slice(2)); -fs.mkdirSync(config.stateDir, { recursive: true }); -const idunnRudpHealthPublisher = createIdunnRudpHealthPublisher(config.idunnRudpHealth); - -const cultRuntime = loadCultRuntime(); -if (cultRuntime.error) { - console.error("CultMesh runtime unavailable; durable mesh catalog disabled:", cultRuntime.error.message); -} - -const documents = defineOdinDocuments(cultRuntime.defineDocumentType); -const allDocumentDefinitions = Object.values(documents).filter(Boolean); -const layoutStore = createLayoutStore({ - definition: documents.interfaceLayoutDefinition, - getNode: async () => meshNodePromise ? await meshNodePromise : null, - layoutPath: config.layoutPath, -}); -const liveProviderRegistry = createLiveProviderRegistry(); -const interfaceDiscovery = createInterfaceDiscovery({ - CultMesh: cultRuntime.CultMesh, - documents, - interfaceBindingStores: config.interfaceBindingStores, - liveProviderRegistry, -}); -const stateBuilder = createStateBuilder({ - cachePath: config.cachePath, - gamecultTextDocumentStorePath: config.gamecultTextDocumentStorePath, - interfaceDiscovery, - layoutStore, - stonksBurstSize: config.stonksBurstSize, -}); - -let meshNodePromise = null; -let cultMeshRudpDocumentServer = null; -let currentState = stateBuilder.buildPendingState("Coordinator starting"); -let lastRefresh = { - completedAt: null, - durationMs: null, - error: null, - startedAt: null, -}; -let lastIdunnRudpHealthPublishedAt = null; -let observedRudpDocuments = 0; - -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); - -async function main() { - if (cultRuntime.CultMesh && documents.surfaceDefinition) { - meshNodePromise = createDurableSurfaceNode(); - } - if (config.cultnetRudpBind) { - cultMeshRudpDocumentServer = createCultMeshRudpDocumentServer({ - CultMesh: cultRuntime.CultMesh, - bind: config.cultnetRudpBind, - documents, - getCache: async () => { - if (!meshNodePromise) { - throw new Error("CultMesh runtime is unavailable; Odin cannot serve a CultMesh/RUDP document catalog."); - } - return (await meshNodePromise).cache; - }, - onDocumentPutRaw: async (document) => { - if (observedRudpDocuments < 40) { - observedRudpDocuments += 1; - console.error( - `Odin RUDP document #${observedRudpDocuments} schema=${document.schemaId} key=${document.recordKey} source=${document.sourceRuntimeId || "unknown"} remote=${document.remote?.address}:${document.remote?.port}`, - ); - } - liveProviderRegistry.ingestDocument(document, document.remote); - await persistRudpDocumentPut(document); - }, - }); - await cultMeshRudpDocumentServer.start(); - console.log(`CultMesh/RUDP document catalog: ${config.cultnetRudpBind}`); - } - - console.log(`Durable surface cache: ${config.cachePath}`); - - await refresh(); - scheduleRefresh(); -} - -async function persistRudpDocumentPut(document) { - if (!meshNodePromise || !document?.schemaId || !document?.recordKey) return; - const definition = documentDefinitionForSchema(document.schemaId); - if (!definition) return; - const node = await meshNodePromise; - await node.put(definition, document.recordKey, normalizeRudpPayload(document.payload)); -} - -function documentDefinitionForSchema(schemaId) { - return allDocumentDefinitions.find((definition) => - definition?.schemaId === schemaId - || definition?.schemaVersion === schemaId - || definition?.schemaName === schemaId - || definition?.type === schemaId - ); -} - -function normalizeRudpPayload(payload) { - if (Array.isArray(payload) && payload.length === 1 && payload[0] && typeof payload[0] === "object" && !Array.isArray(payload[0])) { - return payload[0]; - } - return payload; -} - -function scheduleRefresh() { - setTimeout(() => { - refresh() - .catch((error) => console.error("refresh failed:", error)) - .finally(() => scheduleRefresh()); - }, config.intervalMs); -} - -async function createDurableSurfaceNode() { - try { - return await cultRuntime.CultMesh.createNode(config.cachePath, { documents: allDocumentDefinitions }); - } catch (error) { - if (!fs.existsSync(config.cachePath)) { - throw error; - } - - const corruptPath = `${config.cachePath}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`; - fs.renameSync(config.cachePath, corruptPath); - console.error(`CultMesh surface cache was unreadable and has been quarantined: ${corruptPath}`); - console.error(`CultMesh surface cache read error: ${error.message}`); - return cultRuntime.CultMesh.createNode(config.cachePath, { documents: allDocumentDefinitions }); - } -} - -async function refresh() { - const started = Date.now(); - lastRefresh = { - completedAt: null, - durationMs: null, - error: null, - startedAt: new Date(started).toISOString(), - }; - try { - currentState = await stateBuilder.buildState(); - await persistState(currentState); - lastRefresh = { - completedAt: new Date().toISOString(), - durationMs: Date.now() - started, - error: null, - startedAt: new Date(started).toISOString(), - }; - await publishOdinHealth("active", `Odin provider catalog refreshed in ${lastRefresh.durationMs}ms`); - } catch (error) { - lastRefresh = { - completedAt: new Date().toISOString(), - durationMs: Date.now() - started, - error: error.message, - startedAt: new Date(started).toISOString(), - }; - await publishOdinHealth("failed", `Odin provider refresh failed: ${error.message}`); - throw error; - } -} - -async function publishOdinHealth(state, detail) { - if (!idunnRudpHealthPublisher) return; - try { - await publishIdunnRudpHealth(idunnRudpHealthPublisher, { - state, - detail, - observedAt: new Date().toISOString(), - }); - lastIdunnRudpHealthPublishedAt = Date.now(); - } catch (error) { - const lastPublishedAgeMs = lastIdunnRudpHealthPublishedAt === null - ? Number.POSITIVE_INFINITY - : Date.now() - lastIdunnRudpHealthPublishedAt; - if (lastPublishedAgeMs > Math.max(60_000, config.intervalMs * 4)) { - console.error("Idunn RUDP health publish failed:", error.message); - } - } -} - -async function persistState(state) { - if (config.writeDebugSurfaceJson) { - fs.writeFileSync(path.join(config.stateDir, "latest-surface.json"), JSON.stringify(state, null, 2), "utf8"); - } - if (!meshNodePromise || !documents.surfaceDefinition) { - return; - } - - try { - const node = await meshNodePromise; - await node.put(documents.surfaceDefinition, config.surfaceKey, state); - await node.flush?.(true); - } catch (error) { - console.error("CultMesh snapshot write failed:", error.message); - } -} diff --git a/src/odin/config.cjs b/src/odin/config.cjs deleted file mode 100644 index e9df2b4..0000000 --- a/src/odin/config.cjs +++ /dev/null @@ -1,73 +0,0 @@ -"use strict"; - -const Module = require("module"); -const path = require("path"); -const { parseArgs } = require("./utils.cjs"); - -const repoRoot = path.resolve(__dirname, "..", ".."); - -process.env.NODE_PATH = [ - path.resolve(repoRoot, "..", "CultLib", "packages"), - process.env.NODE_PATH || "", -].filter(Boolean).join(path.delimiter); -Module._initPaths(); - -function buildConfig(argv) { - const args = parseArgs(argv); - const cultnetRudpBind = args["cultnet-rudp-bind"] ? String(args["cultnet-rudp-bind"]) : ""; - const intervalMs = Number(args.intervalMs || 5000); - const stateDir = args.stateDir || path.join(repoRoot, "scratch", "odin"); - const cachePath = args.cachePath || path.join(stateDir, "odin.ccmp"); - const layoutPath = args.layoutPath || path.join(stateDir, "interface-layout.json"); - const writeDebugSurfaceJson = Boolean(args.writeDebugSurfaceJson || args["write-debug-surface-json"] || process.env.ODIN_WRITE_DEBUG_SURFACE_JSON === "1"); - if (args.observationLogPath || args.observationFreshSeconds) { - throw new Error("Odin no longer tails Mimir observation logs. Publish observation records through CultMesh/Odin provider discovery instead."); - } - const gamecultTextDocumentStorePath = args.gamecultTextDocumentStorePath || path.join(repoRoot, "..", "VoidBot", ".voidbot", "private", "gamecult-text-documents.cc"); - const stonksBurstSize = Math.max(1, Number(args.stonksBurstSize || 8)); - const idunnRudpHealth = args["idunn-rudp-health"] ? { - endpoint: String(args["idunn-rudp-health"]), - daemonId: String(args["idunn-daemon"] || "odin"), - healthContract: String(args["idunn-health-contract"] || "odin.cultnet-rudp-provider-health"), - privateKeyPath: args["idunn-health-private-key"] - ? String(args["idunn-health-private-key"]) - : "", - sourceRuntimeId: String(args["idunn-source-runtime"] || "odin-coordinator"), - } : null; - const interfaceBindingStores = String( - args.interfaceBindingStore || - process.env.ODIN_INTERFACE_BINDING_STORES || - "", - ) - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - - return { - args, - cachePath, - cultnetRudpBind, - gamecultTextDocumentStorePath, - interfaceBindingStores, - idunnRudpHealth, - intervalMs, - layoutPath, - repoRoot, - stateDir, - stonksBurstSize, - surfaceKey: "surface:gamecult.network.status", - writeDebugSurfaceJson, - }; -} - -function loadCultRuntime() { - try { - const { CultMesh } = require("cultmesh-ts"); - const { defineDocumentType } = require("cultcache-ts"); - return { CultMesh, defineDocumentType, error: null }; - } catch (error) { - return { CultMesh: null, defineDocumentType: null, error }; - } -} - -module.exports = { buildConfig, loadCultRuntime, repoRoot }; diff --git a/src/odin/cultnet-rudp.cjs b/src/odin/cultnet-rudp.cjs deleted file mode 100644 index 05b290e..0000000 --- a/src/odin/cultnet-rudp.cjs +++ /dev/null @@ -1,115 +0,0 @@ -"use strict"; - -const path = require("path"); -const { createRequire } = require("module"); -const { createSocket } = require("dgram"); - -const requireCultNet = createRequire(path.resolve( - __dirname, - "..", - "..", - "..", - "CultLib", - "packages", - "cultnet-ts", - "package.json", -)); - -const { - CultNetDocumentRegistry, - decodeRudpPacket, - defineCultNetDocumentBinding, -} = requireCultNet("./dist/index.js"); - -const ODIN_RUDP_CONNECTION_ID = 0x0d1d0002; - -function createCultMeshRudpDocumentServer(options) { - if (!options?.bind) return null; - if (!options.CultMesh?.createRudpDocumentServer) { - throw new Error("Odin requires a CultMesh runtime with createRudpDocumentServer."); - } - if (!options.documents?.surfaceDefinition) { - throw new Error("Odin CultMesh/RUDP document server requires a surface document definition."); - } - if (typeof options.getCache !== "function") { - throw new Error("Odin CultMesh/RUDP document server requires an async getCache function."); - } - - const bind = parseEndpoint(options.bind); - const socket = createSocket(bind.host.includes(":") ? "udp6" : "udp4"); - let observedPackets = 0; - socket.on("message", (wire, remote) => { - if (observedPackets >= 20) return; - observedPackets += 1; - try { - const packet = decodeRudpPacket(wire); - console.error( - `Odin RUDP observed packet #${observedPackets} ${packet.packetType} connection=${packet.connectionId.toString(16)} channel=${packet.channelId} from ${remote.address}:${remote.port} bytes=${wire.length}`, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error( - `Odin RUDP observed undecodable packet #${observedPackets} from ${remote.address}:${remote.port} bytes=${wire.length}: ${message}`, - ); - } - }); - const registry = new CultNetDocumentRegistry( - Object.values(options.documents) - .filter(Boolean) - .map((definition) => defineCultNetDocumentBinding({ definition })), - ); - const server = options.CultMesh.createRudpDocumentServer( - "odin-cultmesh-catalog", - ODIN_RUDP_CONNECTION_ID, - { - bindHost: bind.host, - bindPort: bind.port, - documents: registry, - socket, - getCache: options.getCache, - onError: (error) => { - const message = error instanceof Error ? error.message : String(error); - if (typeof options.logError === "function") { - options.logError(`CultMesh/RUDP document server error: ${message}`); - return; - } - console.error(`CultMesh/RUDP document server error: ${message}`); - }, - onDocumentPutRaw: (document) => { - if (typeof options.onDocumentPutRaw === "function") { - return options.onDocumentPutRaw(document); - } - return undefined; - }, - }, - ); - - return { - bind, - start: () => server.start(), - close: () => server.close(), - }; -} - -function parseEndpoint(value) { - const text = String(value || "").trim(); - const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/); - if (ipv6) return { host: ipv6[1], port: parsePort(ipv6[2]) }; - const index = text.lastIndexOf(":"); - if (index <= 0) { - throw new Error(`CultMesh/RUDP bind must be host:port, got "${value}".`); - } - return { host: text.slice(0, index), port: parsePort(text.slice(index + 1)) }; -} - -function parsePort(value) { - const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`CultMesh/RUDP port is invalid: ${value}`); - } - return port; -} - -module.exports = { - createCultMeshRudpDocumentServer, -}; diff --git a/src/odin/documents.cjs b/src/odin/documents.cjs deleted file mode 100644 index 4cf9cfa..0000000 --- a/src/odin/documents.cjs +++ /dev/null @@ -1,26 +0,0 @@ -"use strict"; - -// The swarm document-type catalog, as Odin consumes it. -// -// The 45 definitions used to live here. Almost none of them are Odin's: they -// describe documents that cross service boundaries, and every service that -// decodes another's documents needs the same ones. They lived here because Odin -// was written first. They now live in CultLib as cultcache-ts's -// defineSwarmDocuments, beside the defineDocumentType that builds them, so -// Hermodr can leave without forking a second definition of one wire format. - -const path = require("path"); -const { createRequire } = require("module"); - -const cultLibRoot = process.env.CULTLIB_ROOT - ? path.resolve(process.env.CULTLIB_ROOT) - : path.resolve(__dirname, "..", "..", "..", "CultLib"); - -const requireCultCache = createRequire(path.resolve(cultLibRoot, "packages", "cultcache-ts", "package.json")); - -const { defineSwarmDocuments } = requireCultCache("./dist/index.js"); - -// Odin's consumers know this name. The catalog is not Odin's, but the call is. -const defineOdinDocuments = defineSwarmDocuments; - -module.exports = { defineOdinDocuments, defineSwarmDocuments }; diff --git a/src/odin/idunn-rudp.cjs b/src/odin/idunn-rudp.cjs deleted file mode 100644 index 180d46f..0000000 --- a/src/odin/idunn-rudp.cjs +++ /dev/null @@ -1,65 +0,0 @@ -"use strict"; - -// Idunn health publication, as Odin consumes it. -// -// The transport used to live here: 300-odd lines of socket handling, RUDP -// handshaking, Ed25519 signing, and packet receipt, of which almost nothing was -// about Idunn. That is generic CultNet client work and now lives in CultLib as -// cultnet-ts's signed-daemon-health module. -// -// What remains is what Odin actually needs to know: Idunn's wire contract, and -// the shape of the calls its consumers already make. The constants are defined -// by Idunn in docs/signed-daemon-health-authority.md. They are declared here, -// not decided here; if they change, that document changes first. - -const path = require("path"); -const { createRequire } = require("module"); - -const cultLibRoot = process.env.CULTLIB_ROOT - ? path.resolve(process.env.CULTLIB_ROOT) - : path.resolve(__dirname, "..", "..", "..", "CultLib"); - -const requireCultNet = createRequire(path.resolve(cultLibRoot, "packages", "cultnet-ts", "package.json")); - -const { - CULTNET_RUDP_PROTOCOL_ID, - createSignedDaemonHealthPublisher, - publishSignedDaemonHealth, - signedDaemonHealthPayload, - signedDaemonHealthSigningMessage, -} = requireCultNet("./dist/index.js"); - -// Idunn's contract. Owner: GameCult/Idunn, docs/signed-daemon-health-authority.md. -const IDUNN_HEALTH_CONTRACT = Object.freeze({ - connectionId: 0x1d0d0001, - signedSchemaId: "idunn.signed_daemon_health.v1", - unsignedSchemaId: "idunn.daemon_health", - messageIdPrefix: "odin-health", -}); - -function createIdunnRudpHealthPublisher(options) { - if (!options) return null; - return createSignedDaemonHealthPublisher({ - sourceRuntimeId: "odin-coordinator", - ...options, - contract: IDUNN_HEALTH_CONTRACT, - }); -} - -const publishIdunnRudpHealth = publishSignedDaemonHealth; -const signedHealthPayload = signedDaemonHealthPayload; - -// The signing purpose is Idunn's signed schema id, so a statement signed for -// Idunn cannot verify as another service's health. -function signingMessage(payload) { - return signedDaemonHealthSigningMessage(IDUNN_HEALTH_CONTRACT.signedSchemaId, payload); -} - -module.exports = { - CULTNET_RUDP_PROTOCOL_ID, - IDUNN_HEALTH_CONTRACT, - createIdunnRudpHealthPublisher, - publishIdunnRudpHealth, - signedHealthPayload, - signingMessage, -}; diff --git a/src/odin/interfaces.cjs b/src/odin/interfaces.cjs deleted file mode 100644 index 43ffabd..0000000 --- a/src/odin/interfaces.cjs +++ /dev/null @@ -1,1179 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const { createRequire } = require("module"); -const path = require("path"); -const { fileURLToPath } = require("url"); -const { stableId } = require("./utils.cjs"); - -const requireCultCacheInspection = createRequire( - path.resolve(__dirname, "..", "..", "..", "CultLib", "packages", "cultcache-ts", "package.json"), -); -const { inspectCultCacheBytes } = requireCultCacheInspection("./dist/cult-cache-inspector.js"); - -function createInterfaceDiscovery({ - CultMesh, - documents, - interfaceBindingStores, - liveProviderRegistry, -}) { - async function discoverInterfaces() { - const { interfaces } = await discoverAll(); - return interfaces; - } - - async function discoverProviderAdvertisements() { - const { providerAdvertisements } = await discoverAll(); - return providerAdvertisements; - } - - async function discoverAll() { - const { interfaces: cultMeshInterfaces, providerAdvertisements } = await discoverCultMeshEntries(); - const liveAnnouncements = liveProviderRegistry?.snapshot?.() || { - interfaces: [], - providerAdvertisements: [], - }; - - const interfaces = []; - for (const entry of [...cultMeshInterfaces, ...liveAnnouncements.interfaces]) { - const existingIndex = interfaces.findIndex((candidate) => candidate.providerId === entry.providerId); - if (existingIndex >= 0) { - interfaces[existingIndex] = entry; - } else { - interfaces.push(entry); - } - } - interfaces.sort((left, right) => left.providerId.localeCompare(right.providerId)); - const allProviderAdvertisements = [...providerAdvertisements, ...liveAnnouncements.providerAdvertisements]; - const providersById = new Map(); - for (const provider of allProviderAdvertisements) { - if (provider?.id) { - const existing = providersById.get(String(provider.id)); - providersById.set(String(provider.id), mergeProviderAdvertisement(existing, provider)); - } - } - return { - interfaces, - providerAdvertisements: [...providersById.values()].sort((left, right) => String(left.id).localeCompare(String(right.id))), - }; - } - - function cultMeshDocumentsAvailable() { - const { - interfaceBindingDefinition, - idunnCommandBoundaryDefinition, - idunnDaemonHealthDefinition, - idunnTransportProfileDefinition, - muninnCaptureStreamDefinition, - muninnCommandBoundaryDefinition, - muninnMoveControllerStateDefinition, - muninnMoveIdentityDefinition, - muninnMoveLightCommandDefinition, - muninnMoveMarkerCandidateDefinition, - muninnObsStreamCatalogDefinition, - muninnQuestAccessDefinition, - muninnTelemetrySurfaceDefinition, - muninnTransportProfileDefinition, - operatorStateDefinition, - providerAdvertisementDefinition, - voidbotProviderCatalogDefinition, - stonksCommandBoundaryDefinition, - stonksMarketSnapshotDefinition, - stonksRequestEventDefinition, - stonksTransportProfileDefinition, - streamPixelsCommandBoundaryDefinition, - streamPixelsTransportProfileDefinition, - surfaceDefinition, - viliCommandBoundaryDefinition, - viliTransportProfileDefinition, - weksaCommandBoundaryDefinition, - weksaOperatorStateDefinition, - weksaTransportProfileDefinition, - voidbotSwarmSnapshotDefinition, - } = documents; - return Boolean( - CultMesh && - interfaceBindingDefinition && - idunnCommandBoundaryDefinition && - idunnDaemonHealthDefinition && - idunnTransportProfileDefinition && - muninnCaptureStreamDefinition && - muninnCommandBoundaryDefinition && - muninnMoveControllerStateDefinition && - muninnMoveIdentityDefinition && - muninnMoveLightCommandDefinition && - muninnMoveMarkerCandidateDefinition && - muninnObsStreamCatalogDefinition && - muninnQuestAccessDefinition && - muninnTelemetrySurfaceDefinition && - muninnTransportProfileDefinition && - operatorStateDefinition && - providerAdvertisementDefinition && - voidbotProviderCatalogDefinition && - stonksCommandBoundaryDefinition && - stonksMarketSnapshotDefinition && - stonksRequestEventDefinition && - stonksTransportProfileDefinition && - streamPixelsCommandBoundaryDefinition && - streamPixelsTransportProfileDefinition && - surfaceDefinition && - viliCommandBoundaryDefinition && - viliTransportProfileDefinition && - weksaCommandBoundaryDefinition && - weksaOperatorStateDefinition && - weksaTransportProfileDefinition && - voidbotSwarmSnapshotDefinition - ); - } - - async function discoverCultMeshEntries() { - const { - interfaceBindingDefinition, - idunnCommandBoundaryDefinition, - idunnDaemonHealthDefinition, - idunnTransportProfileDefinition, - muninnCaptureStreamDefinition, - muninnCommandBoundaryDefinition, - muninnMoveControllerStateDefinition, - muninnMoveIdentityDefinition, - muninnMoveLightCommandDefinition, - muninnMoveMarkerCandidateDefinition, - muninnObsStreamCatalogDefinition, - muninnQuestAccessDefinition, - muninnTelemetrySurfaceDefinition, - muninnTransportProfileDefinition, - operatorStateDefinition, - providerAdvertisementDefinition, - voidbotProviderCatalogDefinition, - stonksCommandBoundaryDefinition, - stonksMarketSnapshotDefinition, - stonksRequestEventDefinition, - stonksTransportProfileDefinition, - streamPixelsCommandBoundaryDefinition, - streamPixelsTransportProfileDefinition, - surfaceDefinition, - viliCommandBoundaryDefinition, - viliTransportProfileDefinition, - weksaCommandBoundaryDefinition, - weksaOperatorStateDefinition, - weksaTransportProfileDefinition, - voidbotSwarmSnapshotDefinition, - } = documents; - if (!cultMeshDocumentsAvailable()) { - return { interfaces: [], providerAdvertisements: [] }; - } - - const nodeDocuments = [ - voidbotSwarmSnapshotDefinition, - stonksRequestEventDefinition, - stonksMarketSnapshotDefinition, - providerAdvertisementDefinition, - voidbotProviderCatalogDefinition, - interfaceBindingDefinition, - surfaceDefinition, - idunnCommandBoundaryDefinition, - idunnTransportProfileDefinition, - stonksCommandBoundaryDefinition, - stonksTransportProfileDefinition, - streamPixelsCommandBoundaryDefinition, - streamPixelsTransportProfileDefinition, - idunnDaemonHealthDefinition, - muninnCaptureStreamDefinition, - muninnCommandBoundaryDefinition, - muninnMoveControllerStateDefinition, - muninnMoveIdentityDefinition, - muninnMoveLightCommandDefinition, - muninnMoveMarkerCandidateDefinition, - muninnObsStreamCatalogDefinition, - muninnQuestAccessDefinition, - muninnTelemetrySurfaceDefinition, - muninnTransportProfileDefinition, - operatorStateDefinition, - viliCommandBoundaryDefinition, - viliTransportProfileDefinition, - weksaOperatorStateDefinition, - weksaCommandBoundaryDefinition, - weksaTransportProfileDefinition, - ]; - - const storeEntries = await Promise.all(interfaceBindingStores.map(async (storeSpec) => { - let resolvedStore = null; - try { - resolvedStore = resolveInterfaceBindingStore(storeSpec); - if (!resolvedStore) { - return { interfaces: [], providerAdvertisements: [] }; - } - const { localPath: storePath, sourceId } = resolvedStore; - if (!fs.existsSync(storePath)) { - return { interfaces: [], providerAdvertisements: [] }; - } - const node = await CultMesh.createNode(storePath, { documents: nodeDocuments }); - const typed = inspectCultMeshStore({ - documents, - interfaceBindingDefinition, - muninnTelemetrySurfaceDefinition, - node, - providerAdvertisementDefinition, - sourceId, - storePath, - }); - const inspected = inspectCultMeshStoreBytes(storePath, sourceId); - return { - interfaces: mergeInterfaces([...typed.interfaces, ...inspected.interfaces]), - providerAdvertisements: mergeProviderAdvertisements([ - ...typed.providerAdvertisements, - ...inspected.providerAdvertisements, - ]), - }; - } catch (error) { - const interfaces = []; - const providerAdvertisements = []; - if (resolvedStore?.localPath && fs.existsSync(resolvedStore.localPath)) { - try { - const inspected = inspectCultMeshStoreBytes(resolvedStore.localPath, resolvedStore.sourceId); - interfaces.push(...inspected.interfaces); - providerAdvertisements.push(...inspected.providerAdvertisements); - } catch (fallbackError) { - if (fallbackError?.code !== "ENOENT") { - throw fallbackError; - } - } - } - if (interfaces.length === 0) { - interfaces.push(dashboardUnavailable(`cultmesh:${storeSpec}`, `cultmesh:${storeSpec}`, error.message)); - } - return { interfaces, providerAdvertisements }; - } finally { - resolvedStore?.cleanup?.(); - } - })); - - return { - interfaces: storeEntries.flatMap((entry) => entry.interfaces), - providerAdvertisements: storeEntries.flatMap((entry) => entry.providerAdvertisements), - }; - } - - return { - discoverAll, - discoverProviderAdvertisements, - discoverInterfaces, - }; -} - -function cultMeshProviderInterface({ - advertisement, - state, - storePath, - operatorState = null, - commandBoundary = null, - transportProfile = null, -}) { - const surface = state.surface || null; - const providerId = advertisement.providerId || advertisement.provider?.id || advertisement.id || "unknown-provider"; - const title = state.title || advertisement.title || advertisement.provider?.title || providerId; - const endpoints = sanitizeProviderEndpoints(advertisement.endpoints); - const routes = sanitizeProviderEndpoints(advertisement.routes); - return { - providerId, - title, - state: "active", - detail: `${surface?.root?.kind || surface?.root?.type || "surface"} ${countSurfaceNodes(surface, state)} nodes via provider advertisement`, - version: state.version || 0, - updatedAt: state.updatedAt || advertisement.updatedAt || new Date().toISOString(), - source: `cultmesh:${storePath}`, - manifest: { - ...advertisement, - endpoints, - routes, - }, - canonicalService: advertisement.canonicalService || null, - locatedService: advertisement.locatedService || null, - cultMeshAddress: advertisement.cultMeshAddress || null, - endpoints, - routes, - operatorState, - commandBoundary, - transportProfile, - surface, - }; -} - -function inspectCultMeshStore({ - documents, - interfaceBindingDefinition, - muninnTelemetrySurfaceDefinition, - node, - providerAdvertisementDefinition, - sourceId, -}) { - const { - idunnCommandBoundaryDefinition, - idunnTransportProfileDefinition, - operatorStateDefinition, - stonksCommandBoundaryDefinition, - stonksTransportProfileDefinition, - streamPixelsCommandBoundaryDefinition, - streamPixelsTransportProfileDefinition, - surfaceDefinition, - viliCommandBoundaryDefinition, - viliTransportProfileDefinition, - weksaCommandBoundaryDefinition, - weksaOperatorStateDefinition, - weksaTransportProfileDefinition, - muninnCommandBoundaryDefinition, - muninnTransportProfileDefinition, - } = documents; - - const providerAdvertisements = []; - const interfaces = []; - const advertisements = typeof node.cache?.getAll === "function" - ? node.cache.getAll(providerAdvertisementDefinition).map(unwrapDocumentRecord) - : []; - - for (const advertisement of advertisements) { - const providerId = advertisement?.providerId || advertisement?.provider?.id || advertisement?.id; - if (!providerId) { - continue; - } - const endpoints = sanitizeProviderEndpoints(advertisement.endpoints); - const routes = sanitizeProviderEndpoints(advertisement.routes); - const providerEndpoint = advertisement.cultMeshAddress - || providerTypedEndpoint(routes) - || providerTypedEndpoint(endpoints) - || typedProviderEndpoint(advertisement.provider?.endpoint) - || `cultmesh:${sourceId}`; - providerAdvertisements.push({ - id: providerId, - title: advertisement.title || advertisement.provider?.title || providerId, - description: advertisement.description || "Provider-owned CultMesh advertisement.", - version: String(advertisement.version || 0), - endpoint: providerEndpoint, - canonicalService: advertisement.canonicalService || null, - locatedService: advertisement.locatedService || null, - cultMeshAddress: advertisement.cultMeshAddress || null, - endpoints, - routes, - inputStreams: Array.isArray(advertisement.inputStreams) ? advertisement.inputStreams : [], - activeStreams: Array.isArray(advertisement.activeStreams) ? advertisement.activeStreams : [], - availableSources: Array.isArray(advertisement.availableSources) ? advertisement.availableSources : [], - transportEndpoint: providerTypedEndpoint(routes) - || typedProviderEndpoint(advertisement.provider?.endpoint) - || providerEndpoint, - capabilities: advertisement.provider?.capabilities || advertisement.capabilities || [], - operatorState: providerRecord(node, [ - operatorStateDefinition, - weksaOperatorStateDefinition, - ], advertisement) || null, - commandBoundary: providerRecord(node, [ - idunnCommandBoundaryDefinition, - muninnCommandBoundaryDefinition, - viliCommandBoundaryDefinition, - weksaCommandBoundaryDefinition, - stonksCommandBoundaryDefinition, - streamPixelsCommandBoundaryDefinition, - ], advertisement) || null, - transportProfile: providerRecord(node, [ - idunnTransportProfileDefinition, - muninnTransportProfileDefinition, - viliTransportProfileDefinition, - weksaTransportProfileDefinition, - stonksTransportProfileDefinition, - streamPixelsTransportProfileDefinition, - ], advertisement) || null, - usesCultMesh: true, - transport: advertisement.provider?.transport || "CultMesh provider advertisement", - status: advertisement.status || "unknown", - updatedAt: advertisement.updatedAt || new Date().toISOString(), - source: `cultmesh:${sourceId}`, - commandSurface: advertisement.commandSurface || null, - }); - - const state = providerSurfaceState( - node, - advertisement, - surfaceDefinition, - muninnTelemetrySurfaceDefinition, - ); - if (state?.surface?.root) { - interfaces.push(cultMeshProviderInterface({ - advertisement, - state, - storePath: sourceId, - operatorState: providerRecord(node, [ - operatorStateDefinition, - weksaOperatorStateDefinition, - ], advertisement) || null, - commandBoundary: providerRecord(node, [ - idunnCommandBoundaryDefinition, - muninnCommandBoundaryDefinition, - viliCommandBoundaryDefinition, - weksaCommandBoundaryDefinition, - stonksCommandBoundaryDefinition, - streamPixelsCommandBoundaryDefinition, - ], advertisement) || null, - transportProfile: providerRecord(node, [ - idunnTransportProfileDefinition, - muninnTransportProfileDefinition, - viliTransportProfileDefinition, - weksaTransportProfileDefinition, - stonksTransportProfileDefinition, - streamPixelsTransportProfileDefinition, - ], advertisement) || null, - })); - } - } - - const bindings = typeof node.cache?.getAll === "function" - ? node.cache.getAll(interfaceBindingDefinition).map(unwrapDocumentRecord) - : [unwrapDocumentRecord(node.get(interfaceBindingDefinition, "voidbot.swarm"))].filter(Boolean); - for (const binding of bindings) { - if (!binding?.providerId) { - continue; - } - const state = unwrapDocumentRecord(node.get(surfaceDefinition, binding.providerId)); - const surface = state?.surface || binding.surface || null; - interfaces.push({ - providerId: binding.providerId, - title: binding.title || state?.title || binding.providerId, - state: "active", - detail: `${surface?.root?.kind || binding.kind || "surface"} ${countSurfaceNodes(surface, state)} nodes via CultMesh`, - version: state?.version || 0, - updatedAt: state?.updatedAt || binding.updatedAt || new Date().toISOString(), - source: `cultmesh:${sourceId}`, - manifest: binding.provider || null, - canonicalService: binding.provider?.canonicalService || null, - locatedService: binding.provider?.locatedService || null, - cultMeshAddress: binding.provider?.cultMeshAddress || binding.provider?.endpoint || null, - endpoints: sanitizeProviderEndpoints(binding.provider?.endpoints), - routes: sanitizeProviderEndpoints(binding.provider?.routes), - surface, - }); - } - - return { interfaces, providerAdvertisements }; -} - -function providerSurfaceState(node, advertisement, surfaceDefinition, muninnTelemetrySurfaceDefinition) { - const surfaceKeys = [ - advertisement.surfaceId, - advertisement.surface_id, - advertisement.providerId, - advertisement.provider?.id, - ].filter(Boolean); - for (const surfaceKey of [...new Set(surfaceKeys.map(String))]) { - const surfaceState = unwrapDocumentRecord(node.get?.(surfaceDefinition, surfaceKey)); - const providerId = surfaceState?.providerId || surfaceState?.provider_id; - const updatedAt = surfaceState?.updatedAt || surfaceState?.updated_at; - if (surfaceState?.surface?.root) { - return { - providerId: providerId || advertisement.providerId || advertisement.provider?.id || "unknown-provider", - title: surfaceState.title || advertisement.title || advertisement.provider?.title || advertisement.providerId || "unknown-provider", - version: Number(surfaceState.version || 0), - updatedAt: updatedAt || advertisement.updatedAt || new Date().toISOString(), - surface: surfaceState.surface, - }; - } - } - - const telemetryState = providerTelemetrySurfaceState(node, advertisement, muninnTelemetrySurfaceDefinition); - if (telemetryState) { - return telemetryState; - } - return null; -} - -function providerTelemetrySurfaceState(node, advertisement, muninnTelemetrySurfaceDefinition) { - if (!muninnTelemetrySurfaceDefinition) { - return null; - } - const keys = [ - advertisement.surfaceId, - advertisement.surface_id, - "latest", - ].filter(Boolean); - for (const key of keys) { - const record = unwrapDocumentRecord(node.get?.(muninnTelemetrySurfaceDefinition, key)); - const telemetryState = muninnTelemetryStateFromRecord(record, advertisement); - if (telemetryState) { - return telemetryState; - } - } - return null; -} - -function providerRecord(node, definitions, advertisement) { - const keys = providerRecordKeys(advertisement); - for (const definition of definitions) { - if (!definition) continue; - for (const key of keys) { - const record = unwrapDocumentRecord(node.get?.(definition, key)); - if (record) return record; - } - } - return null; -} - -function unwrapDocumentRecord(record) { - if (Array.isArray(record) && record.length === 1 && record[0] && typeof record[0] === "object") { - return record[0]; - } - return record; -} - -function providerRecordKeys(advertisement) { - const keys = [ - advertisement?.commandSurface?.commandBoundaryId, - advertisement?.commandSurface?.transportProfileId, - advertisement?.providerId, - advertisement?.daemonId, - advertisement?.daemon_id, - advertisement?.serviceId, - advertisement?.service_id, - advertisement?.provider?.id, - advertisement?.canonicalService, - advertisement?.locatedService, - ]; - if (advertisement?.providerId === "vili.animation") keys.push("vili"); - if (advertisement?.providerId === "weksa.intent.service") keys.push("weksa"); - if (advertisement?.providerId === "stonks.market") keys.push("stonks"); - if (advertisement?.provider?.id === "streampixels.service") keys.push("streampixels", "yggdrasil-streampixels"); - return [...new Set(keys.filter(Boolean).map(String))]; -} - -function inspectProviderAdvertisementsFromStore(storePath, sourceId) { - const inspection = inspectCultCacheBytes(storePath, fs.readFileSync(storePath)); - const records = inspection.records || []; - return records - .filter((record) => record.schemaName === "gamecult.eve.provider_advertisement") - .map((record) => buildInspectedProviderAdvertisement(record, records, sourceId)) - .filter(Boolean); -} - -function buildInspectedProviderAdvertisement(record, records, sourceId) { - const advertisement = normalizeInspectedProviderAdvertisement(record?.payloadPreview); - const providerId = advertisement?.providerId; - if (!providerId) { - return null; - } - const commandBoundary = inspectProviderCommandBoundary(providerId, records); - const transportProfile = inspectProviderTransportProfile(providerId, records); - const endpoints = sanitizeProviderEndpoints(advertisement.endpoints); - const routes = sanitizeProviderEndpoints(normalizeInspectedRoutes(advertisement)); - return { - id: providerId, - title: advertisement.title || providerId, - description: advertisement.description || "Provider-owned CultMesh advertisement.", - version: String(advertisement.version || 0), - endpoint: advertisement.cultMeshAddress || providerTypedEndpoint(endpoints) || `cultmesh:${sourceId}`, - canonicalService: advertisement.canonicalService || null, - locatedService: advertisement.locatedService || null, - cultMeshAddress: advertisement.cultMeshAddress || null, - endpoints, - routes, - inputStreams: Array.isArray(advertisement.inputStreams) ? advertisement.inputStreams : [], - activeStreams: Array.isArray(advertisement.activeStreams) ? advertisement.activeStreams : [], - availableSources: Array.isArray(advertisement.availableSources) ? advertisement.availableSources : [], - transportEndpoint: advertisement.cultMeshAddress || providerTypedEndpoint(routes) || providerTypedEndpoint(endpoints) || `cultmesh:${sourceId}`, - capabilities: advertisement.capabilities || [], - operatorState: null, - commandBoundary, - transportProfile, - usesCultMesh: true, - transport: "CultMesh provider advertisement", - status: advertisement.status || "unknown", - updatedAt: advertisement.updatedAt || new Date().toISOString(), - source: `cultmesh:${sourceId}`, - commandSurface: advertisement.commandSurface || null, - }; -} - -function inspectCultMeshInterfacesFromStore(storePath, sourceId) { - const inspection = inspectCultCacheBytes(storePath, fs.readFileSync(storePath)); - const records = inspection.records || []; - const providersById = new Map( - records - .filter((record) => record.schemaName === "gamecult.eve.provider_advertisement") - .map((record) => buildInspectedProviderAdvertisement(record, records, sourceId)) - .filter(Boolean) - .map((provider) => [provider.id, provider]), - ); - const surfaceInterfaces = records - .filter((record) => record.schemaName === "gamecult.eve.surface_state") - .map((record) => buildInspectedInterfaceFromSurfaceRecord( - record, - providersById, - sourceId, - records, - )) - .filter(Boolean); - const telemetryInterfaces = records - .filter((record) => record.schemaName === "muninn.telemetry_surface") - .map((record) => buildInspectedInterfaceFromTelemetryRecord( - record, - providersById, - sourceId, - records, - )) - .filter(Boolean); - return [...surfaceInterfaces, ...telemetryInterfaces]; -} - -function inspectCultMeshStoreBytes(storePath, sourceId) { - return { - interfaces: inspectCultMeshInterfacesFromStore(storePath, sourceId), - providerAdvertisements: inspectProviderAdvertisementsFromStore(storePath, sourceId), - }; -} - -function mergeInterfaces(entries) { - const byProvider = new Map(); - for (const entry of entries) { - if (!entry?.providerId) continue; - const existing = byProvider.get(String(entry.providerId)); - if (!existing || (!existing.surface?.root && entry.surface?.root)) { - byProvider.set(String(entry.providerId), entry); - } - } - return [...byProvider.values()]; -} - -function mergeProviderAdvertisements(entries) { - const byProvider = new Map(); - for (const entry of entries) { - if (!entry?.id) continue; - const existing = byProvider.get(String(entry.id)); - byProvider.set(String(entry.id), mergeProviderAdvertisement(existing, entry)); - } - return [...byProvider.values()]; -} - -function mergeProviderAdvertisement(existing, entry) { - if (!existing) return entry; - return { - ...existing, - ...entry, - endpoints: preferNonEmptyArray(entry.endpoints, existing.endpoints), - routes: preferNonEmptyArray(entry.routes, existing.routes), - inputStreams: preferNonEmptyArray(entry.inputStreams, existing.inputStreams), - activeStreams: preferNonEmptyArray(entry.activeStreams, existing.activeStreams), - availableSources: preferNonEmptyArray(entry.availableSources, existing.availableSources), - capabilities: preferNonEmptyArray(entry.capabilities, existing.capabilities), - }; -} - -function preferNonEmptyArray(candidate, fallback) { - return Array.isArray(candidate) && candidate.length > 0 - ? candidate - : (Array.isArray(fallback) ? fallback : []); -} - -function buildInspectedInterfaceFromSurfaceRecord(record, providersById, sourceId, records) { - const state = normalizeInspectedSurfaceState(record?.payloadPreview); - if (!state?.providerId || !state.surface?.root) { - return null; - } - const provider = providersById.get(state.providerId) || null; - return { - providerId: state.providerId, - title: state.title || provider?.title || state.providerId, - state: "active", - detail: `${state.surface.root.kind || "surface"} ${countSurfaceNodes(state.surface)} nodes via CultMesh witness inspection`, - version: state.version || 0, - updatedAt: state.updatedAt || provider?.updatedAt || new Date().toISOString(), - source: `cultmesh:${sourceId}`, - manifest: provider ? { - providerId: provider.id, - title: provider.title, - description: provider.description, - canonicalService: provider.canonicalService, - locatedService: provider.locatedService, - cultMeshAddress: provider.cultMeshAddress, - endpoints: sanitizeProviderEndpoints(provider.endpoints), - routes: sanitizeProviderEndpoints(provider.routes), - } : null, - canonicalService: provider?.canonicalService || null, - locatedService: provider?.locatedService || null, - cultMeshAddress: provider?.cultMeshAddress || null, - endpoints: sanitizeProviderEndpoints(provider?.endpoints), - routes: sanitizeProviderEndpoints(provider?.routes), - operatorState: null, - commandBoundary: provider?.commandBoundary || inspectProviderCommandBoundary(state.providerId, records), - transportProfile: provider?.transportProfile || inspectProviderTransportProfile(state.providerId, records), - surface: state.surface, - }; -} - -function buildInspectedInterfaceFromTelemetryRecord(record, providersById, sourceId, records) { - const telemetry = normalizeInspectedMuninnTelemetrySurfaceRecord(record?.payloadPreview); - if (!telemetry?.hostId) { - return null; - } - const providerId = `muninn.telemetry.${telemetry.hostId}`; - const provider = providersById.get(providerId) || null; - const state = muninnTelemetryStateFromRecord(telemetry, provider); - if (!state?.surface?.root) { - return null; - } - return { - providerId: state.providerId, - title: state.title, - state: "active", - detail: `${state.surface.root.kind || "surface"} ${countSurfaceNodes(state.surface)} nodes via Muninn telemetry surface`, - version: state.version || 0, - updatedAt: state.updatedAt || provider?.updatedAt || new Date().toISOString(), - source: `cultmesh:${sourceId}`, - manifest: provider ? { - providerId: provider.id, - title: provider.title, - description: provider.description, - canonicalService: provider.canonicalService, - locatedService: provider.locatedService, - cultMeshAddress: provider.cultMeshAddress, - endpoints: sanitizeProviderEndpoints(provider.endpoints), - routes: sanitizeProviderEndpoints(provider.routes), - } : null, - canonicalService: provider?.canonicalService || null, - locatedService: provider?.locatedService || null, - cultMeshAddress: provider?.cultMeshAddress || null, - endpoints: sanitizeProviderEndpoints(provider?.endpoints), - routes: sanitizeProviderEndpoints(provider?.routes), - operatorState: null, - commandBoundary: provider?.commandBoundary || null, - transportProfile: provider?.transportProfile || null, - surface: state.surface, - }; -} - -function muninnTelemetryStateFromRecord(record, advertisement = null) { - const telemetry = normalizeMuninnTelemetrySurfaceRecord(record); - if (!telemetry?.hostId) { - return null; - } - const providerId = advertisement?.providerId || advertisement?.id || `muninn.telemetry.${telemetry.hostId}`; - const title = advertisement?.title || advertisement?.provider?.title || `Muninn ${titleCase(telemetry.hostId)} Telemetry`; - const updatedAt = telemetry.updatedAt || advertisement?.updatedAt || new Date().toISOString(); - const parsedVersion = Date.parse(updatedAt); - return { - providerId, - title, - version: Number.isFinite(parsedVersion) ? parsedVersion : 0, - updatedAt, - surface: buildMuninnTelemetrySurfaceDocument(providerId, title, telemetry), - }; -} - -function normalizeMuninnTelemetrySurfaceRecord(record) { - if (!record) { - return null; - } - if (!Array.isArray(record) && typeof record === "object") { - return { - surfaceId: record.surface_id || record.surfaceId || null, - hostId: record.host_id || record.hostId || null, - state: record.state || "unknown", - availableSources: Array.isArray(record.available_sources || record.availableSources) - ? (record.available_sources || record.availableSources) - : [], - streamAffordances: Array.isArray(record.stream_affordances || record.streamAffordances) - ? (record.stream_affordances || record.streamAffordances) - : [], - activeStreams: Array.isArray(record.active_streams || record.activeStreams) - ? (record.active_streams || record.activeStreams) - : [], - activationAuthority: record.activation_authority || record.activationAuthority || "", - detail: record.detail || "", - updatedAt: record.updated_at || record.updatedAt || null, - }; - } - return normalizeInspectedMuninnTelemetrySurfaceRecord(record); -} - -function normalizeInspectedMuninnTelemetrySurfaceRecord(preview) { - if (Array.isArray(preview) && preview.length === 1 && preview[0] && typeof preview[0] === "object" && !Array.isArray(preview[0])) { - return normalizeMuninnTelemetrySurfaceRecord(preview[0]); - } - if (!Array.isArray(preview) || preview.length < 9) { - return null; - } - return { - surfaceId: preview[0] || null, - hostId: preview[1] || null, - state: preview[2] || "unknown", - availableSources: Array.isArray(preview[3]) ? preview[3] : [], - streamAffordances: Array.isArray(preview[4]) ? preview[4] : [], - activeStreams: Array.isArray(preview[5]) ? preview[5] : [], - activationAuthority: preview[6] || "", - detail: preview[7] || "", - updatedAt: preview[8] || null, - }; -} - -function buildMuninnTelemetrySurfaceDocument(providerId, title, telemetry) { - const sourceSummary = telemetry.availableSources.length ? telemetry.availableSources.join(", ") : "none"; - const activeSummary = telemetry.activeStreams.length ? telemetry.activeStreams.join(", ") : "none"; - const affordanceSummary = telemetry.streamAffordances.length ? telemetry.streamAffordances.join(", ") : "none"; - return { - schema: "gamecult.eve.surface.v1", - id: telemetry.surfaceId || `${providerId}.surface`, - title, - root: { - id: `${providerId}.root`, - kind: "dashboard", - props: { - title, - summary: telemetry.detail || `${telemetry.state} on ${telemetry.hostId}`, - status: telemetry.state || "unknown", - }, - children: [ - { - id: `${providerId}.runtime`, - kind: "card", - props: { title: "Runtime" }, - children: [ - textElement(`${providerId}.state`, `state: ${telemetry.state || "unknown"}`), - textElement(`${providerId}.host`, `host: ${telemetry.hostId}`), - textElement(`${providerId}.authority`, `activation: ${telemetry.activationAuthority || "unknown"}`), - textElement(`${providerId}.updated`, `updated: ${telemetry.updatedAt || "unknown"}`), - ], - }, - { - id: `${providerId}.sources`, - kind: "card", - props: { title: "Sources" }, - children: [ - textElement(`${providerId}.available-sources`, `available: ${sourceSummary}`), - textElement(`${providerId}.affordances`, `affordances: ${affordanceSummary}`), - ], - }, - { - id: `${providerId}.streams`, - kind: "card", - props: { title: "Streams" }, - children: [ - textElement(`${providerId}.active-streams`, `active: ${activeSummary}`), - textElement(`${providerId}.detail`, telemetry.detail || "detail: none"), - ], - }, - ], - }, - assets: [], - }; -} - -function normalizeInspectedProviderAdvertisement(preview) { - if (Array.isArray(preview) && preview.length === 1 && preview[0] && typeof preview[0] === "object" && !Array.isArray(preview[0])) { - const value = preview[0]; - return { - providerId: value.providerId || value.ProviderId || null, - title: value.title || value.Title || null, - description: value.description || value.Description || null, - canonicalService: value.canonicalService || value.CanonicalService || null, - locatedService: value.locatedService || value.LocatedService || null, - cultMeshAddress: value.cultMeshAddress || value.CultMeshAddress || null, - status: value.status || value.Status || null, - updatedAt: value.updatedAt || value.UpdatedAt || null, - capabilities: value.capabilities || value.CapabilityIds || [], - endpoints: value.endpoints || value.Endpoints || [], - commandSurface: value.commandSurface || value.CommandSurface || null, - }; - } - if (!Array.isArray(preview) || preview.length < 11) { - return null; - } - return { - providerId: preview[1] || null, - serviceId: preview[2] || null, - verseId: preview[3] || null, - title: preview[4] || null, - description: preview[5] || null, - canonicalService: preview[6] || null, - locatedService: preview[7] || null, - cultMeshAddress: preview[8] || null, - status: preview[9] || null, - updatedAt: preview[10] || null, - capabilities: Array.isArray(preview[11]) ? preview[11] : [], - endpoints: Array.isArray(preview[12]) ? preview[12] : [], - schemas: Array.isArray(preview[13]) ? preview[13] : [], - witnesses: Array.isArray(preview[14]) ? preview[14] : [], - surfaces: Array.isArray(preview[15]) ? preview[15] : [], - }; -} - -function normalizeInspectedSurfaceState(preview) { - if (Array.isArray(preview) && preview.length === 1 && preview[0] && typeof preview[0] === "object" && !Array.isArray(preview[0])) { - const value = preview[0]; - return { - providerId: value.providerId || value.ProviderId || null, - title: value.title || value.Title || null, - version: Number(value.version || value.Version || 0), - updatedAt: value.updatedAt || value.UpdatedAt || null, - surface: normalizeInspectedSurface(value.surface || value.Surface || null), - }; - } - if (!Array.isArray(preview) || preview.length < 5) { - return null; - } - if (preview.length >= 6) { - return { - providerId: preview[1] || null, - title: preview[2] || null, - version: Number(preview[3] || 0), - updatedAt: preview[4] || null, - surface: normalizeInspectedSurface(preview[5]), - }; - } - return { - providerId: preview[0] || null, - title: preview[1] || null, - version: Number(preview[2] || 0), - updatedAt: preview[3] || null, - surface: normalizeInspectedSurface(preview[4]), - }; -} - -function normalizeInspectedSurface(preview) { - if (!preview) { - return null; - } - if (!Array.isArray(preview) && typeof preview === "object") { - return { - ...preview, - root: normalizeInspectedSurfaceNode(preview.root), - assets: Array.isArray(preview.assets) ? preview.assets : [], - }; - } - if (!Array.isArray(preview) || preview.length < 4) { - return null; - } - return { - schema: preview[0] || "gamecult.eve.surface.v1", - id: preview[1] || "surface", - title: preview[2] || "", - root: normalizeInspectedSurfaceNode(preview[3]), - assets: Array.isArray(preview[4]) ? preview[4] : [], - }; -} - -function normalizeInspectedSurfaceNode(preview) { - if (!preview) { - return null; - } - if (!Array.isArray(preview) && typeof preview === "object") { - return { - ...preview, - props: normalizeInspectedSurfaceProps(preview.props), - children: Array.isArray(preview.children) - ? preview.children.map((child) => normalizeInspectedSurfaceNode(child)).filter(Boolean) - : [], - }; - } - if (!Array.isArray(preview) || preview.length < 3) { - return null; - } - return { - id: preview[0] || "node", - kind: preview[1] || "group", - props: normalizeInspectedSurfaceProps(preview[2]), - children: Array.isArray(preview[3]) - ? preview[3].map((child) => normalizeInspectedSurfaceNode(child)).filter(Boolean) - : [], - }; -} - -function normalizeInspectedSurfaceProps(preview) { - if (!preview) { - return {}; - } - if (!Array.isArray(preview) && typeof preview === "object") { - return preview; - } - if (!Array.isArray(preview)) { - return {}; - } - return { - title: preview[0] || "", - text: preview[1] || "", - label: preview[2] || "", - value: preview[3] || "", - status: preview[4] || "", - summary: preview[5] || "", - detail: preview[6] || "", - }; -} - -function inspectProviderCommandBoundary(providerId, records) { - if (providerId !== "nightwing-gjallar") { - return null; - } - const record = records.find((entry) => entry.schemaName === "gjallar.command_boundary"); - if (!record || !Array.isArray(record.payloadPreview)) { - return null; - } - return { - boundary_id: record.payloadPreview[0] || null, - daemon_id: record.payloadPreview[1] || null, - owner: record.payloadPreview[2] || null, - lifecycle_authority: record.payloadPreview[5] || null, - updated_at: record.payloadPreview[8] || null, - }; -} - -function inspectProviderTransportProfile(providerId, records) { - if (providerId !== "nightwing-gjallar") { - return null; - } - const record = records.find((entry) => entry.schemaName === "gjallar.transport_profile"); - if (!record || !Array.isArray(record.payloadPreview)) { - return null; - } - return { - profile_id: record.payloadPreview[0] || null, - daemon_id: record.payloadPreview[1] || null, - state: record.payloadPreview[2] || null, - debug_transport: record.payloadPreview[3] || null, - current_transport: record.payloadPreview[4] || null, - health_contract: record.payloadPreview[5] || null, - cut_line: record.payloadPreview[9] || null, - updated_at: record.payloadPreview[10] || null, - }; -} - -function normalizeInspectedEndpoints(endpoints) { - if (!Array.isArray(endpoints)) { - return []; - } - return endpoints.map((entry) => { - if (entry && typeof entry === "object" && !Array.isArray(entry)) { - return entry; - } - return { - transport: "witness", - address: String(entry || ""), - }; - }); -} - -function sanitizeProviderEndpoints(endpoints) { - return normalizeInspectedEndpoints(endpoints).filter((entry) => { - const transport = String(entry?.transport || "").toLowerCase(); - const address = String(entry?.address || "").trim(); - if (address.startsWith("cultmesh:")) { - return true; - } - if (transport.includes("cultmesh") || transport.includes("cultcache")) { - return true; - } - return false; - }); -} - -function providerTypedEndpoint(endpoints) { - const normalized = sanitizeProviderEndpoints(endpoints); - return normalized.find((entry) => { - const address = String(entry?.address || ""); - return address.startsWith("cultmesh:"); - })?.address || null; -} - -function typedProviderEndpoint(endpoint) { - const value = typeof endpoint === "string" ? endpoint.trim() : ""; - if (!value) { - return null; - } - return value.startsWith("cultmesh:") ? value : null; -} - -function normalizeInspectedRoutes(advertisement) { - if (Array.isArray(advertisement?.routes)) { - return advertisement.routes; - } - if (Array.isArray(advertisement?.endpoints)) { - return normalizeInspectedEndpoints(advertisement.endpoints); - } - return []; -} - -function resolveInterfaceBindingStore(storeSpec) { - if (typeof storeSpec !== "string" || !storeSpec.trim()) { - return null; - } - const text = storeSpec.trim(); - if (text.startsWith("cultmesh-store:file://")) { - const localPath = fileURLToPath(text.slice("cultmesh-store:".length)); - return { - localPath, - sourceId: text, - cleanup: () => {}, - }; - } - if (text.startsWith("cultmesh://")) { - throw new Error(`CultMesh rendezvous URI ${text} must announce through Odin; static interface store imports are not the rendezvous owner.`); - } - if (text.startsWith("sftp://") || text.startsWith("http://") || text.startsWith("https://") || text.startsWith("rudp://")) { - throw new Error(`network interface binding stores are no longer supported by Odin discovery: ${text}`); - } - - throw new Error(`raw interface binding store paths are no longer supported by Odin discovery: ${text}. Use CultMesh announcement through Odin, or an explicit cultmesh-store:file:// URI for a local debug import.`); -} - -function dashboardUnavailable(providerId, source, detail) { - return { - providerId, - title: providerId, - state: "unavailable", - detail, - version: 0, - updatedAt: new Date().toISOString(), - source, - manifest: null, - surface: null, - }; -} - -function countSurfaceNodes(surface, state = null) { - if (Array.isArray(state?.nodes)) { - return state.nodes.length; - } - if (!surface?.root) { - return 0; - } - - let count = 0; - const stack = [surface.root]; - while (stack.length > 0) { - const node = stack.pop(); - if (!node || typeof node !== "object") { - continue; - } - count += 1; - if (Array.isArray(node.children)) { - for (const child of node.children) { - stack.push(child); - } - } - } - return count; -} - -function textElement(id, text) { - return { - id: String(id), - kind: "text", - props: { text: String(text) }, - children: [], - }; -} - -function titleCase(value) { - return String(value || "") - .split(/[^a-zA-Z0-9]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -module.exports = { - createInterfaceDiscovery, - dashboardUnavailable, -}; diff --git a/src/odin/layout.cjs b/src/odin/layout.cjs deleted file mode 100644 index 66db6fd..0000000 --- a/src/odin/layout.cjs +++ /dev/null @@ -1,279 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const { clampNumber, positiveNumber, stableId } = require("./utils.cjs"); - -const DEFAULT_LAYOUT_ID = "odin.providers"; - -function createLayoutStore({ definition, getNode, layoutPath } = {}) { - let cachedLayout = null; - let attemptedMigration = false; - - return { - async readLayout() { - const meshLayout = await readCultMeshLayout(); - if (meshLayout) return meshLayout; - if (!attemptedMigration) { - attemptedMigration = true; - const migrated = readJsonMigrationLayout(layoutPath); - if (migrated) { - cachedLayout = migrated; - await this.writeLayout(migrated).catch(() => undefined); - return normalizeLayout(migrated); - } - } - return cachedLayout || defaultLayout(); - }, - - async writeLayout(layout) { - const normalized = normalizeLayout(layout); - cachedLayout = normalized; - const node = await layoutNode(); - if (!node || !definition) { - throw new Error("Odin CultMesh layout store unavailable; refusing to write layout sidecar fallback."); - } - await node.put(definition, DEFAULT_LAYOUT_ID, normalized); - await node.flush?.(true); - }, - - async applyClientCommand(command) { - if (!command || command.type !== "odin-layout-intent" || !command.providerId) return; - const layout = await this.readLayout(); - layout.updatedAt = new Date().toISOString(); - layout.tiles ||= {}; - const current = layout.tiles[command.providerId] || defaultLayoutFor({ providerId: command.providerId }, []); - const next = { ...current }; - if (command.action === "focus") { - Object.assign(next, fullscreenLayoutIntent(command.providerId, -100)); - } else if (command.action === "resize") { - next.w = clampNumber((next.w || 1) + Number(command.dw || 0), 1, 4); - next.h = clampNumber((next.h || 1) + Number(command.dh || 0), 1, 4); - } else if (command.action === "move") { - next.x = clampNumber((next.x || 0) + Number(command.dx || 0), 0, 12); - next.y = clampNumber((next.y || 0) + Number(command.dy || 0), 0, 12); - } else if (command.action === "toggle") { - next.visible = !next.visible; - } else { - return; - } - layout.tiles[command.providerId] = next; - await this.writeLayout(layout); - }, - }; - - async function readCultMeshLayout() { - const node = await layoutNode(); - if (!node || !definition) return null; - const record = await node.get(definition, DEFAULT_LAYOUT_ID); - const normalized = record ? normalizeLayout(record) : null; - if (normalized) cachedLayout = normalized; - return normalized; - } - - async function layoutNode() { - return typeof getNode === "function" ? await getNode() : null; - } -} - -function defaultLayout() { - return { schema: "odin.interface_layout.v1", layoutId: DEFAULT_LAYOUT_ID, tiles: {} }; -} - -function normalizeLayout(layout) { - return { - ...defaultLayout(), - ...(layout && typeof layout === "object" ? layout : {}), - schema: "odin.interface_layout.v1", - layoutId: DEFAULT_LAYOUT_ID, - tiles: layout && typeof layout.tiles === "object" && layout.tiles ? layout.tiles : {}, - }; -} - -function readJsonMigrationLayout(layoutPath) { - if (!layoutPath) return null; - try { - return normalizeLayout(JSON.parse(fs.readFileSync(layoutPath, "utf8"))); - } catch { - return null; - } -} - -function defaultLayoutFor(entry, interfaces) { - const index = Math.max(0, interfaces.findIndex((candidate) => candidate.providerId === entry.providerId)); - const intent = surfaceLayoutIntent(entry); - const preferredHeight = positiveNumber(intent.preferredHeight, entry.providerId === "voidbot.swarm" ? 18 : 12); - const minHeight = positiveNumber(intent.minHeight, 6); - const preferredWidth = positiveNumber(intent.preferredWidth, 72); - const minWidth = positiveNumber(intent.minWidth, 36); - const priority = Number.isFinite(intent.priority) ? intent.priority : index; - return { - tileId: stableId(entry.providerId || "interface"), - visible: true, - priority, - x: index % 2, - y: Math.floor(index / 2), - w: 1, - h: Math.max(1, Math.ceil(preferredHeight / 12)), - minWidth, - minHeight, - preferredWidth, - preferredHeight, - density: intent.density || "dense", - viewportMode: intent.viewportMode || "nested-scroll", - signalWeight: intent.signalWeight || 1, - tree: intent.tree || null, - }; -} - -function fullscreenLayoutIntent(tileId, priority = -100) { - return { - tileId: stableId(tileId || "fullscreen"), - visible: true, - priority, - x: 0, - y: 0, - w: 4, - h: 4, - minWidth: 96, - minHeight: 36, - preferredWidth: 192, - preferredHeight: 48, - density: "comfortable", - viewportMode: "fullscreen", - }; -} - -function mergeLayoutIntent(existing, entry, interfaces) { - const base = existing && typeof existing === "object" - ? { ...existing } - : defaultLayoutFor(entry, interfaces); - const intent = surfaceLayoutIntent(entry); - const fallback = defaultLayoutFor(entry, interfaces); - const denseTree = isDenseLayoutStrategy(intent.layoutStrategy); - const minWidth = positiveNumber(intent.minWidth, positiveNumber(base.minWidth, fallback.minWidth)); - const minHeight = positiveNumber(intent.minHeight, positiveNumber(base.minHeight, fallback.minHeight)); - const preferredWidth = positiveNumber(intent.preferredWidth, positiveNumber(base.preferredWidth, fallback.preferredWidth)); - const preferredHeight = positiveNumber(intent.preferredHeight, positiveNumber(base.preferredHeight, fallback.preferredHeight)); - const desiredW = Math.max(1, Math.min(4, Math.ceil(preferredWidth / 72))); - const desiredH = Math.max(1, Math.min(4, Math.ceil(preferredHeight / 12))); - return { - ...base, - minWidth, - minHeight, - preferredWidth, - preferredHeight, - h: denseTree && intent.viewportMode !== "fullscreen" ? desiredH : Math.max(positiveNumber(base.h, fallback.h), desiredH), - w: denseTree && intent.viewportMode !== "fullscreen" ? desiredW : Math.max(positiveNumber(base.w, fallback.w), desiredW), - priority: Number.isFinite(intent.priority) ? Math.min(Number(base.priority ?? intent.priority), intent.priority) : base.priority, - density: intent.density || base.density || fallback.density, - viewportMode: intent.viewportMode || base.viewportMode || fallback.viewportMode, - layoutStrategy: intent.layoutStrategy || base.layoutStrategy || fallback.layoutStrategy, - signalWeight: intent.signalWeight || base.signalWeight || fallback.signalWeight || 1, - tree: intent.tree || base.tree || fallback.tree || null, - }; -} - -function surfaceLayoutIntent(entry) { - const root = entry?.surface?.root; - if (!root || typeof root !== "object") return {}; - const layout = root.layout && typeof root.layout === "object" ? root.layout : {}; - const props = root.props && typeof root.props === "object" ? root.props : {}; - const propLayout = props.layout && typeof props.layout === "object" ? props.layout : {}; - const tree = analyzeElementTree(root); - const derived = denseTreeLayoutIntent(tree); - return normalizeDenseSurfaceIntent({ ...derived, ...propLayout, ...layout, tree }, derived); -} - -function analyzeElementTree(root) { - const stats = { - elementCount: 0, - leafCount: 0, - branchCount: 0, - maxDepth: 0, - textCells: 0, - listLikeBranchCount: 0, - kindCounts: {}, - }; - - visit(root, 1); - stats.signalWeight = Math.max(1, stats.branchCount * 2 + stats.leafCount + Math.ceil(stats.textCells / 48)); - return stats; - - function visit(element, depth) { - if (!element || typeof element !== "object") return; - stats.elementCount += 1; - stats.maxDepth = Math.max(stats.maxDepth, depth); - const kind = String(element.kind || "unknown"); - stats.kindCounts[kind] = (stats.kindCounts[kind] || 0) + 1; - const text = textValue(element); - stats.textCells += text.length + Math.max(0, text.split(/\r?\n/).length - 1) * 8; - const children = Array.isArray(element.children) ? element.children.filter((child) => child && typeof child === "object") : []; - if (!children.length) { - stats.leafCount += 1; - return; - } - stats.branchCount += 1; - if (children.length >= 4 && children.every((child) => String(child.kind || "") === "text" && !(Array.isArray(child.children) && child.children.length))) { - stats.listLikeBranchCount += 1; - } - for (const child of children) visit(child, depth + 1); - } -} - -function denseTreeLayoutIntent(tree) { - const signal = Math.max(1, tree.signalWeight || 1); - const depthPressure = Math.max(0, tree.maxDepth - 2); - const textPressure = Math.sqrt(Math.max(1, tree.textCells) / 18); - const branchPressure = Math.sqrt(Math.max(1, tree.branchCount)); - return { - minWidth: Math.min(72, 28 + depthPressure * 7), - minHeight: Math.min(18, 5 + depthPressure * 2), - preferredWidth: Math.min(144, 44 + Math.sqrt(signal) * 7 + depthPressure * 5), - preferredHeight: Math.min(36, 8 + textPressure + branchPressure * 3 + tree.listLikeBranchCount * 2), - density: "dense", - viewportMode: "nested-scroll", - layoutStrategy: "dense-tree", - signalWeight: signal, - }; -} - -function normalizeDenseSurfaceIntent(intent, derived) { - const viewportMode = intent.viewportMode || derived.viewportMode; - if (viewportMode === "fullscreen") { - return intent; - } - return { - ...intent, - minWidth: Math.min(72, positiveNumber(intent.minWidth, derived.minWidth)), - minHeight: Math.min(18, positiveNumber(intent.minHeight, derived.minHeight)), - preferredWidth: Math.min(144, positiveNumber(intent.preferredWidth, derived.preferredWidth)), - preferredHeight: Math.min(36, positiveNumber(intent.preferredHeight, derived.preferredHeight)), - density: intent.density || derived.density || "dense", - viewportMode, - layoutStrategy: intent.layoutStrategy || derived.layoutStrategy || "dense-tree", - signalWeight: intent.signalWeight || derived.signalWeight || 1, - }; -} - -function isDenseLayoutStrategy(strategy) { - const value = String(strategy || ""); - return value === "dense-tree" || value.startsWith("dense-"); -} - -function textValue(element) { - if (typeof element.text === "string") return element.text; - const props = element.props && typeof element.props === "object" ? element.props : {}; - if (typeof props.text === "string") return props.text; - if (typeof props.title === "string") return props.title; - return ""; -} - -module.exports = { - analyzeElementTree, - createLayoutStore, - defaultLayoutFor, - denseTreeLayoutIntent, - fullscreenLayoutIntent, - mergeLayoutIntent, - surfaceLayoutIntent, -}; diff --git a/src/odin/marquee.cjs b/src/odin/marquee.cjs deleted file mode 100644 index 375954f..0000000 --- a/src/odin/marquee.cjs +++ /dev/null @@ -1,111 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const { - CultCache, - SingleFileMessagePackBackingStore, - defineDocumentRegistry, - defineDocumentType, -} = require("cultcache-ts"); - -const textDocumentSetDefinition = defineDocumentType({ - type: "gamecult.text_document_set", - global: true, - schema: { - parse(value) { - if (!value || typeof value !== "object") { - throw new Error("gamecult.text_document_set must be an object"); - } - return value; - }, - }, -}); - -async function buildMarqueeText({ interfaces, textDocumentStorePath, stonksBurstSize = 8 }) { - const [securities, stanzas] = await Promise.all([ - Promise.resolve(stonksSegments({ interfaces })), - poetryStanzas(textDocumentStorePath), - ]); - return stanzaBurstTape(stanzas, securities, stonksBurstSize).join(" / "); -} - -function stonksSegments({ interfaces }) { - return stonksSegmentsFromInterfaces(interfaces); -} - -function stonksSegmentsFromInterfaces(interfaces) { - const stonks = interfaces.find((entry) => String(entry.providerId || "").toLowerCase() === "stonks.market"); - const explicit = String(stonks?.surface?.root?.props?.marqueeText || "").trim(); - return splitSegments(explicit).slice(0, 16); -} - -async function poetryStanzas(textDocumentStorePath) { - if (!textDocumentStorePath || !fs.existsSync(textDocumentStorePath)) return []; - try { - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(textDocumentSetDefinition)) - .withGenericStore(new SingleFileMessagePackBackingStore(textDocumentStorePath)) - .build(); - await cache.pullAllBackingStores(); - const documentSet = cache.getGlobal(textDocumentSetDefinition); - return array(documentSet?.documents).flatMap(documentStanzas); - } catch { - return []; - } -} - -function documentStanzas(document) { - const stanzas = []; - let stanza = []; - for (const line of array(document?.lines)) { - if (isStanzaMarker(line)) { - pushStanza(stanzas, stanza); - stanza = []; - continue; - } - - const text = array(line).join(" / ").replace(/\s+/g, " ").trim(); - if (text) stanza.push(text); - } - pushStanza(stanzas, stanza); - return stanzas; -} - -function pushStanza(stanzas, stanza) { - if (stanza.length) stanzas.push(stanza.join(" / ")); -} - -function stanzaBurstTape(stanzas, securities, burstSize) { - const output = []; - const stonkBurstSize = Math.max(1, Number(burstSize) || 8); - const stanzaList = stanzas.length ? stanzas : []; - const securityList = securities.length ? securities : []; - const cycles = Math.max(stanzaList.length, securityList.length ? Math.ceil(securityList.length / stonkBurstSize) : 0); - for (let index = 0; index < cycles; index += 1) { - if (stanzaList.length) output.push(stanzaList[index % stanzaList.length]); - for (let offset = 0; offset < stonkBurstSize && securityList.length; offset += 1) { - output.push(securityList[(index * stonkBurstSize + offset) % securityList.length]); - } - } - return output; -} - -function splitSegments(text) { - return String(text || "") - .split(/\s+\/\s+/) - .map((segment) => segment.replace(/\s+/g, " ").trim()) - .filter(Boolean); -} - -function isStanzaMarker(line) { - const parts = array(line); - return parts.length === 1 && String(parts[0]).trim().toLowerCase() === "[stanza]"; -} - -function array(value) { - return Array.isArray(value) ? value : []; -} - -module.exports = { - buildMarqueeText, -}; diff --git a/src/odin/probes.cjs b/src/odin/probes.cjs deleted file mode 100644 index 7e4c5b5..0000000 --- a/src/odin/probes.cjs +++ /dev/null @@ -1,48 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const { execFile } = require("child_process"); -const { promisify } = require("util"); - -const execFileAsync = promisify(execFile); - -async function dockerSnapshot() { - try { - const { stdout } = await execFileAsync("docker", ["ps", "--format", "{{json .}}"], { timeout: 3000 }); - const containers = stdout.split(/\r?\n/) - .filter(Boolean) - .map((line) => JSON.parse(line)) - .map((item) => ({ - name: item.Names, - image: item.Image, - status: item.Status, - ports: item.Ports, - })); - return { state: "ok", containers, error: "" }; - } catch (error) { - return { state: "error", containers: [], error: error.message }; - } -} - -async function adbSnapshot() { - const adb = path.join(process.env.LOCALAPPDATA || "", "Android", "Sdk", "platform-tools", "adb.exe"); - if (!fs.existsSync(adb)) { - return { state: "missing", devices: [], error: `missing ${adb}` }; - } - try { - const { stdout } = await execFileAsync(adb, ["devices"], { timeout: 3000 }); - const devices = stdout.split(/\r?\n/) - .map((line) => line.match(/^(\S+)\s+(device|offline|unauthorized)$/)) - .filter(Boolean) - .map((match) => ({ serial: match[1], state: match[2] })); - return { state: "ok", devices, error: "" }; - } catch (error) { - return { state: "error", devices: [], error: error.message }; - } -} - -module.exports = { - adbSnapshot, - dockerSnapshot, -}; diff --git a/src/odin/provider-ingress.cjs b/src/odin/provider-ingress.cjs deleted file mode 100644 index 4a35711..0000000 --- a/src/odin/provider-ingress.cjs +++ /dev/null @@ -1,278 +0,0 @@ -"use strict"; - -function createLiveProviderRegistry(options = {}) { - const maxAgeMs = Math.max(1_000, Number(options.maxAgeMs || 120_000)); - const records = new Map(); - - function ingestDocument(document, source = {}) { - if (!document?.schemaId || !document?.recordKey) { - return; - } - const now = Date.now(); - records.set(`${document.schemaId}:${document.recordKey}`, { - ...document, - receivedAtMs: now, - source, - }); - prune(now); - } - - function snapshot() { - const now = Date.now(); - prune(now); - - const advertisements = new Map(); - const surfaces = new Map(); - const commandBoundaries = new Map(); - const transportProfiles = new Map(); - - for (const record of records.values()) { - const payload = normalizeDocumentPayload(record); - switch (record.schemaId) { - case "gamecult.eve.provider_advertisement": - case "gamecult.eve.provider_advertisement.v1": - if (payload?.providerId) { - advertisements.set(String(payload.providerId), { ...record, payload }); - } - break; - case "gamecult.eve.surface_state": - case "gamecult.eve.surface_state.v0": - case "gamecult.eve.surface_state.v1": - if (surfaceProviderId(payload)) { - surfaces.set(String(surfaceProviderId(payload)), { ...record, payload }); - } - break; - default: { - const providerId = payload.provider_id || payload.providerId; - if (!providerId) break; - if (String(record.schemaId).endsWith(".command_boundary")) { - commandBoundaries.set(String(providerId), payload); - } else if (String(record.schemaId).endsWith(".transport_profile")) { - transportProfiles.set(String(providerId), payload); - } - break; - } - } - } - - const providerAdvertisements = [...advertisements.values()] - .map((record) => normalizeProviderAdvertisementRecord(record, commandBoundaries, transportProfiles)) - .filter(Boolean) - .sort((left, right) => String(left.id).localeCompare(String(right.id))); - - const interfaces = providerAdvertisements - .map((provider) => { - const surfaceRecord = surfaces.get(String(provider.id)); - if (!surfaceRecord?.payload?.surface?.root) { - return null; - } - return liveProviderInterface(provider, surfaceRecord.payload, surfaceRecord.source); - }) - .filter(Boolean) - .sort((left, right) => String(left.providerId).localeCompare(String(right.providerId))); - - return { interfaces, providerAdvertisements }; - } - - function prune(now = Date.now()) { - for (const [key, record] of records) { - if (now - record.receivedAtMs > maxAgeMs) { - records.delete(key); - } - } - } - - return { - ingestDocument, - snapshot, - }; -} - -function normalizeDocumentPayload(record) { - const payload = record?.payload; - switch (record?.schemaId) { - case "gamecult.eve.surface_state": - case "gamecult.eve.surface_state.v0": - case "gamecult.eve.surface_state.v1": - if (Array.isArray(payload) && payload.length >= 5) { - return { - provider_id: payload[0], - title: payload[1], - version: payload[2], - updated_at: payload[3], - surface: payload[4], - }; - } - break; - default: - break; - } - let normalized = payload; - for (let depth = 0; depth < 4; depth += 1) { - if (Array.isArray(normalized) && normalized.length === 1 && normalized[0] && typeof normalized[0] === "object") { - normalized = normalized[0]; - continue; - } - if (normalized?.value && typeof normalized.value === "object" && !Array.isArray(normalized.value)) { - normalized = normalized.value; - continue; - } - break; - } - return normalized || {}; -} - -function surfaceProviderId(surfaceState) { - return surfaceState?.providerId || surfaceState?.provider_id || null; -} - -function surfaceTitle(surfaceState) { - return surfaceState?.title || surfaceState?.Title || null; -} - -function surfaceVersion(surfaceState) { - return surfaceState?.version || surfaceState?.Version || 0; -} - -function surfaceUpdatedAt(surfaceState) { - return surfaceState?.updatedAt || surfaceState?.updated_at || surfaceState?.UpdatedAt || null; -} - -function normalizeProviderAdvertisementRecord(record, commandBoundaries, transportProfiles) { - const advertisement = record.payload; - const providerId = advertisement?.providerId || advertisement?.provider?.id || advertisement?.id; - if (!providerId) { - return null; - } - const endpoints = sanitizeProviderEndpoints(advertisement.endpoints); - const routes = sanitizeProviderEndpoints(advertisement.routes); - const providerEndpoint = advertisement.cultMeshAddress - || providerTypedEndpoint(routes) - || providerTypedEndpoint(endpoints) - || typedProviderEndpoint(advertisement.provider?.endpoint) - || `cultmesh:live:${providerId}`; - return { - id: providerId, - title: advertisement.title || advertisement.provider?.title || providerId, - description: advertisement.description || "Provider-owned CultNet/RUDP advertisement.", - version: String(advertisement.version || 0), - endpoint: providerEndpoint, - canonicalService: advertisement.canonicalService || null, - locatedService: advertisement.locatedService || null, - cultMeshAddress: advertisement.cultMeshAddress || null, - endpoints, - routes, - inputStreams: Array.isArray(advertisement.inputStreams) ? advertisement.inputStreams : [], - activeStreams: Array.isArray(advertisement.activeStreams) ? advertisement.activeStreams : [], - availableSources: Array.isArray(advertisement.availableSources) ? advertisement.availableSources : [], - transportEndpoint: providerTypedEndpoint(routes) - || typedProviderEndpoint(advertisement.provider?.endpoint) - || providerEndpoint, - capabilities: advertisement.provider?.capabilities || advertisement.capabilities || [], - operatorState: null, - commandBoundary: commandBoundaries.get(String(providerId)) || null, - transportProfile: transportProfiles.get(String(providerId)) || null, - usesCultMesh: false, - transport: "CultNet/RUDP provider advertisement", - status: advertisement.status || "unknown", - updatedAt: advertisement.updatedAt || record.storedAt || new Date(record.receivedAtMs).toISOString(), - source: sourceLabel(record.source), - commandSurface: advertisement.commandSurface || null, - }; -} - -function typedProviderEndpoint(endpoint) { - const value = typeof endpoint === "string" ? endpoint.trim() : ""; - if (!value) { - return null; - } - return value.startsWith("cultmesh:") ? value : null; -} - -function normalizeProviderEndpoints(endpoints) { - if (!Array.isArray(endpoints)) { - return []; - } - return endpoints.map((entry) => { - if (entry && typeof entry === "object" && !Array.isArray(entry)) { - return entry; - } - return { - transport: "witness", - address: String(entry || ""), - }; - }); -} - -function sanitizeProviderEndpoints(endpoints) { - return normalizeProviderEndpoints(endpoints).filter((entry) => { - const transport = String(entry?.transport || "").toLowerCase(); - const address = String(entry?.address || "").trim(); - if (address.startsWith("cultmesh:")) { - return true; - } - if (transport.includes("cultmesh") || transport.includes("cultcache")) { - return true; - } - return false; - }); -} - -function providerTypedEndpoint(endpoints) { - return sanitizeProviderEndpoints(endpoints).find((entry) => - String(entry?.address || "").startsWith("cultmesh:") - )?.address || null; -} - -function liveProviderInterface(provider, surfaceState, source) { - const surface = surfaceState.surface || null; - return { - providerId: provider.id, - title: surfaceTitle(surfaceState) || provider.title || provider.id, - state: "active", - detail: `${surface?.root?.kind || surface?.root?.type || "surface"} ${countSurfaceNodes(surface, surfaceState)} nodes via live CultNet/RUDP announcement`, - version: surfaceVersion(surfaceState), - updatedAt: surfaceUpdatedAt(surfaceState) || provider.updatedAt || new Date().toISOString(), - source: sourceLabel(source), - manifest: provider, - canonicalService: provider.canonicalService || null, - locatedService: provider.locatedService || null, - cultMeshAddress: provider.cultMeshAddress || null, - endpoints: sanitizeProviderEndpoints(provider.endpoints), - routes: sanitizeProviderEndpoints(provider.routes), - commandBoundary: provider.commandBoundary || null, - transportProfile: provider.transportProfile || null, - surface, - }; -} - -function countSurfaceNodes(surface, state = null) { - if (Array.isArray(state?.nodes)) { - return state.nodes.length; - } - if (!surface?.root) { - return 0; - } - let count = 0; - const stack = [surface.root]; - while (stack.length > 0) { - const node = stack.pop(); - if (!node || typeof node !== "object") continue; - count += 1; - if (Array.isArray(node.children)) { - for (const child of node.children) stack.push(child); - } - } - return count; -} - -function sourceLabel(source) { - if (!source?.address) { - return "cultnet-rudp:unknown"; - } - return `cultnet-rudp://${source.address}:${source.port}`; -} - -module.exports = { - createLiveProviderRegistry, -}; diff --git a/src/odin/state.cjs b/src/odin/state.cjs deleted file mode 100644 index cbcb5ea..0000000 --- a/src/odin/state.cjs +++ /dev/null @@ -1,216 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const { dashboardUnavailable } = require("./interfaces.cjs"); -const { - adbSnapshot, - dockerSnapshot, -} = require("./probes.cjs"); -const { buildMarqueeText } = require("./marquee.cjs"); -const { buildPendingSurface, buildSurface } = require("./surface.cjs"); -const { stableId } = require("./utils.cjs"); - -function createStateBuilder({ cachePath, gamecultTextDocumentStorePath, interfaceDiscovery, layoutStore, stonksBurstSize }) { - let version = 0; - - function buildPendingState(message) { - return { - type: "dashboard-state", - schema: "mimir.eve_dashboard_state.v1", - providerId: "odin.providers", - title: "Odin Provider Catalog", - version, - updatedAt: new Date().toISOString(), - selectedNodeId: "coordinator-starting", - lutPreset: "terminal", - nodes: [], - providerCatalog: [], - surface: buildPendingSurface(message), - }; - } - - async function buildState() { - version += 1; - const observedAt = new Date().toISOString(); - const [ - docker, - adb, - discovery, - ] = await Promise.all([ - dockerSnapshot(), - adbSnapshot(), - interfaceDiscovery.discoverAll(), - ]); - const discoveredInterfaces = discovery.interfaces; - const providerAdvertisements = discovery.providerAdvertisements; - const interfaceById = new Map(discoveredInterfaces.map((entry) => [entry.providerId, entry])); - const interfaces = [...interfaceById.values()]; - const marqueeText = await buildMarqueeText({ interfaces, textDocumentStorePath: gamecultTextDocumentStorePath, stonksBurstSize }); - const interfaceSummary = interfaces.map((entry) => `${entry.providerId}:${entry.state}`).join(", "); - const layout = await layoutStore.readLayout(); - const verseEvidence = [...providerAdvertisements, ...interfaces]; - const voidBotDashboard = interfaceById.get("voidbot.swarm") || dashboardUnavailable("voidbot.swarm", "discovery", "not discovered"); - const mimirLiveStats = interfaceById.get("mimir.live.stats") || dashboardUnavailable("mimir.live.stats", "discovery", "not discovered"); - const periwinkleServices = [ - ...adbFaultServices(adb), - ]; - - const verses = [ - verse("starfire.local", "Starfire", "coordinator", "active", ["docker", "adb", "eve-provider"], [ - service("odin", "Odin all-seer", "active", "asgard.starfire.odin/eve/tui"), - service("docker", "Docker", docker.state === "ok" ? "active" : "warn", `${docker.containers.length} running`), - service("adb", "ADB transport", adbState(adb), adbDetail(adb)), - service("cultcache", "Odin CultCache", fs.existsSync(cachePath) ? "active" : "waiting", require("path").basename(cachePath)), - service("interfaces", "Eve interfaces", interfaces.some((entry) => entry.surface?.root) ? "active" : "waiting", interfaceSummary || "no provider surfaces"), - service("voidbot-swarm", "VoidBot Swarm", voidBotDashboard.state, voidBotDashboard.detail), - service("mimir-live-stats", "Mimir Live Stats", mimirLiveStats.state, mimirLiveStats.detail), - ...docker.containers.map((container) => service(`docker-${container.name}`, container.name, "active", container.image)), - ]), - verse("nightwing.local", "Nightwing", "dashboard-renderer", bodyStatus(verseEvidence, "nightwing"), ["eve-tui", "gpu-worker"], bodyServices(verseEvidence, "nightwing")), - verse("eve.ipad", "EVE", "ios-client", bodyStatus(verseEvidence, "eve"), ["native-eve"], bodyServices(verseEvidence, "eve")), - periwinkleServices.length ? verse("periwinkle.android", "Periwinkle", "android-client", periwinkleStatus(periwinkleServices), ["sensor-edge", "adb-transport"], periwinkleServices) : null, - verse("raven.local", "Raven", "local-peer", bodyStatus(verseEvidence, "raven"), ["cultmesh-provider"], bodyServices(verseEvidence, "raven")), - verse("yggdrasil.ops", "Yggdrasil", "ops-host", bodyStatus(verseEvidence, "yggdrasil"), ["cultmesh-provider"], bodyServices(verseEvidence, "yggdrasil")), - ].filter(Boolean); - - return { - type: "dashboard-state", - schema: "mimir.eve_dashboard_state.v1", - providerId: "odin.providers", - title: "Odin Provider Catalog", - version, - updatedAt: observedAt, - selectedNodeId: "verse-starfire.local", - lutPreset: "terminal", - nodes: verses.map((entry, index) => ({ - id: `verse-${entry.verseId}`, - label: `${entry.name}\n${entry.role}`, - kind: "cultmesh-verse", - visible: true, - x: -0.8 + (index % 3) * 0.8, - y: -0.45 + Math.floor(index / 3) * 0.55, - z: 0, - rotation: 0, - scale: 1, - width: 0.42, - height: 0.2, - health: entry.status, - detail: entry.capabilities.join(", "), - })), - providerCatalog: providerAdvertisements, - surface: buildSurface({ observedAt, docker, adb, verses, interfaces, layout, marqueeText }), - }; - } - - return { buildPendingState, buildState }; -} - -function verse(verseId, name, role, status, capabilities, services = []) { - return { verseId, name, role, status, capabilities, services }; -} - -function service(id, name, state, detail = "") { - return { id: stableId(id), name, state, detail: String(detail || "") }; -} - -function bodyStatus(entries, body) { - const matches = bodyEntries(entries, body); - if (!matches.length) return "waiting"; - if (matches.some((entry) => providerState(entry) === "active")) return "active"; - if (matches.some((entry) => providerState(entry) === "warn")) return "warn"; - return "waiting"; -} - -function bodyServices(entries, body) { - const matches = bodyEntries(entries, body); - if (!matches.length) { - return [service(`${body}-cultmesh-witnesses`, `${titleCase(body)} CultMesh witnesses`, "waiting", "no provider advertisement or interface discovered")]; - } - - return matches - .slice(0, 8) - .map((entry) => service( - `${body}-${entry.providerId || entry.id || entry.title || "provider"}`, - entry.title || entry.providerId || entry.id || `${titleCase(body)} provider`, - providerState(entry), - providerDetail(entry), - )); -} - -function bodyEntries(entries, body) { - const token = String(body).toLowerCase(); - return entries.filter((entry) => providerBodyText(entry).includes(token)); -} - -function providerBodyText(entry) { - return [ - entry.providerId, - entry.id, - entry.title, - entry.canonicalService, - entry.locatedService, - entry.cultMeshAddress, - entry.verseId, - entry.source, - ].filter(Boolean).join(" ").toLowerCase(); -} - -function providerState(entry) { - const state = String(entry.state || entry.status || "").toLowerCase(); - if (["active", "ok", "ready", "running", "published"].includes(state)) return "active"; - if (["warn", "warning", "degraded"].includes(state)) return "warn"; - if (["failed", "error", "offline"].includes(state)) return "warn"; - return entry.surface?.root ? "active" : "waiting"; -} - -function providerDetail(entry) { - return [ - entry.detail, - entry.cultMeshAddress, - entry.locatedService, - entry.source, - ].filter(Boolean).join(" / ") || "provider-owned CultMesh discovery"; -} - -function titleCase(value) { - const text = String(value || ""); - return text ? text[0].toUpperCase() + text.slice(1) : ""; -} - -function adbState(adb) { - if (adb.state !== "ok") return adb.state; - return adbFaultDevices(adb).length ? "warn" : "ok"; -} - -function adbDetail(adb) { - if (adb.error) return adb.error; - if (adb.devices.length === 0) return "no devices"; - return adb.devices.map((device) => `${device.serial}:${device.state}`).join(", "); -} - -function adbFaultDevices(adb) { - return adb.devices.filter((device) => device.state !== "device"); -} - -function adbFaultServices(adb) { - if (adb.state !== "ok") { - return [service("adb-transport", "ADB transport", adb.state, adbDetail(adb))]; - } - - return adbFaultDevices(adb).map((device) => - service(`adb-${device.serial}`, "ADB device fault", device.state, `${device.serial}:${device.state}`), - ); -} - -function periwinkleStatus(services) { - if (services.some((entry) => entry.state === "active")) return "active"; - if (services.some((entry) => entry.state === "stale")) return "stale"; - if (services.some((entry) => entry.state === "offline" || entry.state === "unauthorized" || entry.state === "error" || entry.state === "missing")) return "warn"; - return "waiting"; -} - -module.exports = { - createStateBuilder, - service, - verse, -}; diff --git a/src/odin/surface.cjs b/src/odin/surface.cjs deleted file mode 100644 index b03dbae..0000000 --- a/src/odin/surface.cjs +++ /dev/null @@ -1,212 +0,0 @@ -"use strict"; - -const { analyzeElementTree, fullscreenLayoutIntent, mergeLayoutIntent } = require("./layout.cjs"); -const { stableId } = require("./utils.cjs"); - -function buildSurface({ observedAt, docker, adb, verses, interfaces, layout, marqueeText = "" }) { - const activeInterfaces = interfaces.filter((entry) => entry?.surface?.root); - return { - schema: "gamecult.eve.surface.v1", - id: "gamecult.network.status.surface", - title: "Odin", - root: { - id: "network-root", - kind: "dashboard", - props: { - title: "Odin Provider Catalog", - observedAt, - summary: `${activeInterfaces.length} Eve surfaces`, - marqueeText, - layout: fullscreenLayoutIntent("odin.providers", -100), - presentation: { - theme: "bifrost-pride", - frame: "animated-rainbow", - dividerMotion: "bifrost-rainbow-dance", - intent: "Odin preserves provider-owned surfaces for catalog/proxy access; Gjallar owns Nightwing overview composition and lowering.", - }, - }, - children: [ - ...activeInterfaces.map((entry) => { - const tree = analyzeElementTree(entry.surface.root); - return { - id: `interface-${stableId(entry.providerId)}`, - kind: "interface", - props: { - title: entry.title || entry.providerId, - verseUri: verseUri(entry), - canonicalService: entry.canonicalService || "", - locatedService: entry.locatedService || "", - cultMeshAddress: entry.cultMeshAddress || "", - endpoints: entry.endpoints || [], - routes: entry.routes || [], - providerId: entry.providerId, - source: entry.source, - status: entry.state, - detail: entry.detail, - overviewSignal: hasOverviewSignal(entry), - version: entry.version, - updatedAt: entry.updatedAt, - layout: mergeLayoutIntent(layout.tiles?.[entry.providerId], entry, interfaces), - tree, - packing: { - strategy: "nested-dense-signal", - flattening: "forbidden-when-surface-root-has-children", - listPolicy: "group-or-tree; never render provider signal as a single log/list blob", - }, - }, - children: [entry.surface.root], - }; - }), - ], - }, - assets: [], - }; -} - -function hasOverviewSignal(entry) { - const root = entry?.surface?.root; - if (!root) return false; - - const providerId = String(entry.providerId || "").toLowerCase(); - const explicit = root.props?.overview || entry.manifest?.overview; - if (explicit?.visible === false) return false; - if (explicit?.visible === true || explicit?.signal === "live-ops") return true; - - const text = surfaceText(root); - if (providerId.includes("voidbot.swarm")) return /\bctb\b|\bs[0-9.]+\s+h[0-9.]+/i.test(text); - if (providerId.includes("stonks.market")) return stonksHasOverviewSignal(root, text); - if (providerId.includes("mimir.stream.layout")) return false; - if (providerId.includes("spotiverse")) return spotiverseHasOverviewSignal(root, text); - if (providerId.includes("streampixels")) return streamPixelsHasOverviewSignal(root, text); - if (providerId.includes("mimir.live.stats")) return mimirHasOverviewSignal(root, text); - - if (/\b(rate limit|playing:\s*none|queue:\s*empty|unavailable|not discovered)\b/i.test(text)) return false; - return hasMetricElement(root) || /\b(users?|viewers?|events?|traffic|ingest|throughput|latency|dropout|queue|pressure|saturation|error|fault|warning)\b/i.test(text); -} - -function stonksHasOverviewSignal(root, text) { - if (root.props?.overview?.visible === true) return true; - return /\b(Market pulse|BITCOIN|ETHEREUM|SOLANA|DOGECOIN|equities|crypto|vol|request traffic|source health)\b/i.test(text); -} - -function spotiverseHasOverviewSignal(root, text) { - const status = String(root.props?.status || "").toLowerCase(); - if (status === "warn" && /\brate limit\b/i.test(text)) return false; - if (/\bplaying:\s*none\b/i.test(text)) return false; - if (/\bqueue:\s*empty|queue:\s*empty or unavailable\b/i.test(text)) return false; - return /\bplaying:\s*(?!none\b).+|\bqueue:\s*(?!empty\b|0\b).+|\brequests?:\s*[1-9]|\bvolume:\s*[1-9]/i.test(text); -} - -function streamPixelsHasOverviewSignal(root, text) { - return [ - /\b(live\s*)?(users?|viewers?)\s*[:=]?\s*[1-9]/i, - /\bevents?(\s*traffic|\s*\/\s*s|\s*per\s*sec)?\s*[:=]?\s*[1-9]/i, - /\bingest\b.*\b(saturation|pressure|traffic|throughput|[1-9][0-9]*(\.[0-9]+)?)\b/i, - /\b(queue|backlog|pressure)\s*[:=]?\s*[1-9]/i, - /\b(errors?|dropped|5xx)\s*[:=]?\s*[1-9]/i, - /\bthroughput\s*[:=]?\s*[1-9]/i, - ].some((pattern) => pattern.test(text)); -} - -function mimirHasOverviewSignal(root, text) { - return [ - /\bdropout\b.*\b[1-9]/i, - /\bstale\s+streams?\s*[:=]?\s*[1-9]/i, - /\blatency\b.*\b([2-9][0-9]{2,}|[1-9]\d+\.\d+ms)\b/i, - /\bconfidence\b.*\b0\.[0-4]/i, - ].some((pattern) => pattern.test(text)); -} - -function surfaceText(root) { - const parts = []; - visitSurface(root, (node) => { - const props = node.props || {}; - for (const key of ["title", "label", "text", "status", "detail", "summary", "value"]) { - if (props[key] !== undefined && props[key] !== null) parts.push(String(props[key])); - } - }); - return parts.join("\n"); -} - -function hasMetricElement(root) { - let found = false; - visitSurface(root, (node) => { - if (node.kind === "metric") found = true; - }); - return found; -} - -function visitSurface(node, visitor) { - if (!node || typeof node !== "object") return; - visitor(node); - for (const child of Array.isArray(node.children) ? node.children : []) { - visitSurface(child, visitor); - } -} - -function buildPendingSurface(message) { - return { - schema: "gamecult.eve.surface.v1", - id: "gamecult.network.status.surface", - title: "Odin", - root: stack("root", [pane("Coordinator", [text("status", message)])]), - assets: [], - }; -} - -function stack(id, children) { - return { id, kind: "stack", props: {}, children }; -} - -function pane(title, children) { - return { id: stableId(`pane-${title}`), kind: "pane", props: { title }, children }; -} - -function card(id, children, tone = "default") { - return { id, kind: "card", props: { title: id }, style: { tone }, children }; -} - -function text(id, value) { - return { id, kind: "text", props: { text: value }, children: [] }; -} - -function metric(id, label, value, tone) { - return { id, kind: "metric", props: { label, text: `${label}: ${value}`, value, tone }, children: [] }; -} - -function verseUri(entry) { - if (entry.cultMeshAddress) return String(entry.cultMeshAddress); - if (entry.locatedService) return String(entry.locatedService); - const providerId = String(entry.providerId || "").trim(); - if (hasBodyPrefix(providerId)) return providerId; - const body = sourceBody(entry.source); - if (!body || providerId.startsWith(`${body}.`)) return providerId; - return `${body}.${providerId}`; -} - -function hasBodyPrefix(providerId) { - return ["starfire", "yggdrasil", "nightwing", "eve", "periwinkle", "raven"].some((body) => providerId.startsWith(`${body}.`)); -} - -function sourceBody(source) { - const value = String(source || ""); - if (value.startsWith("cultmesh:")) return "starfire"; - - return ""; -} - -function adbTone(adb) { - if (adb.state !== "ok") return "warn"; - return adb.devices.some((device) => device.state !== "device") ? "warn" : "ok"; -} - -module.exports = { - buildPendingSurface, - buildSurface, - card, - hasOverviewSignal, - metric, - pane, - stack, - text, -}; diff --git a/test/ghostlight-discovery-documents.test.cjs b/test/ghostlight-discovery-documents.test.cjs deleted file mode 100644 index 5c96e9e..0000000 --- a/test/ghostlight-discovery-documents.test.cjs +++ /dev/null @@ -1,21 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const test = require("node:test"); - -const { defineOdinDocuments } = require("../src/odin/documents.cjs"); - -test("Odin persists Ghostlight's public schema catalog envelope", () => { - const documents = defineOdinDocuments((definition) => definition); - const catalog = documents.ghostlightSchemaCatalogDefinition; - - assert.equal(catalog.type, "ghostlight.schema_catalog"); - assert.equal(catalog.schemaId, "ghostlight.schema_catalog.v1"); - assert.equal(catalog.schemaVersion, "ghostlight.schema_catalog.v1"); - assert.equal(catalog.name({ providerId: "gamecult.ghostlight.dungeon" }), "gamecult.ghostlight.dungeon"); -}); - -test("Odin's unavailable-runtime projection includes the Ghostlight catalog slot", () => { - const documents = defineOdinDocuments(null); - assert.equal(documents.ghostlightSchemaCatalogDefinition, null); -}); diff --git a/test/heimdall-discovery-documents.test.cjs b/test/heimdall-discovery-documents.test.cjs deleted file mode 100644 index f4f6e47..0000000 --- a/test/heimdall-discovery-documents.test.cjs +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const test = require("node:test"); - -const { defineOdinDocuments } = require("../src/odin/documents.cjs"); - -test("Odin registers the typed Heimdall access discovery documents", () => { - const documents = defineOdinDocuments((definition) => definition); - - assert.equal( - documents.evePluginAdvertisementDefinition.schemaId, - "gamecult.eve.plugin_advertisement.v1", - ); - assert.equal( - documents.heimdallCommandBoundaryDefinition.schemaId, - "heimdall.command_boundary.v1", - ); - assert.equal( - documents.heimdallTransportProfileDefinition.schemaId, - "heimdall.transport_profile.v1", - ); -}); diff --git a/test/idunn-signed-health.test.cjs b/test/idunn-signed-health.test.cjs deleted file mode 100644 index 75b86a0..0000000 --- a/test/idunn-signed-health.test.cjs +++ /dev/null @@ -1,67 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const crypto = require("node:crypto"); -const test = require("node:test"); - -const { - IDUNN_HEALTH_CONTRACT, - signedHealthPayload, - signingMessage, -} = require("../src/odin/idunn-rudp.cjs"); - -test("Odin signs the complete canonical provider-health statement", () => { - const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519"); - const publicDer = publicKey.export({ type: "spki", format: "der" }); - const rawPublicKey = Buffer.from(publicDer.subarray(publicDer.length - 32)); - const signerIdentityId = crypto - .createHash("sha256") - .update(Buffer.from("gamecult.provider-health.identity.v1\0")) - .update(rawPublicKey) - .digest("hex"); - const publisher = { - daemonId: "yggdrasil-odin", - healthContract: "odin.cultnet-rudp-provider-health", - privateKey, - publisherIncarnationId: "24e0dc7f-6a92-4aa8-9f56-fb513d265e13", - publisherSequence: 0, - signerIdentityId, - sourceRuntimeId: "odin-yggdrasil", - contract: IDUNN_HEALTH_CONTRACT, - }; - - const signed = signedHealthPayload( - publisher, - { state: "active", detail: "catalog current" }, - "2026-08-21T12:34:56.789Z", - ); - - assert.equal(signed.statement[0], "idunn.signed_daemon_health.v1"); - assert.equal(signed.statement[1], "yggdrasil-odin"); - assert.equal(signed.statement[2], "odin.cultnet-rudp-provider-health"); - assert.equal(signed.statement[3], "odin-yggdrasil"); - assert.equal(signed.statement[8], 1); - assert.equal(signed.statement[9], 1787315696789); - assert.equal(signed.statement[15].byteLength, 64); - assert.equal( - crypto.verify( - null, - signingMessage(signed.unsignedPayload), - publicKey, - Buffer.from(signed.statement[15]), - ), - true, - ); - - const tampered = Buffer.from(signed.unsignedPayload); - tampered[tampered.length - 1] ^= 1; - assert.equal( - crypto.verify( - null, - signingMessage(tampered), - publicKey, - Buffer.from(signed.statement[15]), - ), - false, - ); -}); diff --git a/test/provider-ingress.test.cjs b/test/provider-ingress.test.cjs deleted file mode 100644 index 076831c..0000000 --- a/test/provider-ingress.test.cjs +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const test = require("node:test"); - -const { createLiveProviderRegistry } = require("../src/odin/provider-ingress.cjs"); - -test("live provider registry unwraps Rust compatibility records", () => { - const registry = createLiveProviderRegistry(); - registry.ingestDocument({ - schemaId: "gamecult.eve.provider_advertisement.v1", - recordKey: "muninn.telemetry.nightwing", - payload: [{ - value: { - providerId: "muninn.telemetry.nightwing", - title: "Muninn Nightwing Telemetry", - status: "active", - cultMeshAddress: "asgard.nightwing.muninn/telemetry", - capabilities: ["muninn.move_evidence_stream"], - }, - }], - }, { address: "10.77.0.3", port: 40000 }); - - const snapshot = registry.snapshot(); - assert.equal(snapshot.providerAdvertisements.length, 1); - assert.equal(snapshot.providerAdvertisements[0].id, "muninn.telemetry.nightwing"); - assert.deepEqual(snapshot.providerAdvertisements[0].capabilities, ["muninn.move_evidence_stream"]); - assert.equal(snapshot.providerAdvertisements[0].source, "cultnet-rudp://10.77.0.3:40000"); -}); From 092ff997b466395493d2b211b8c543439f61ce90 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 00:22:19 +0200 Subject: [PATCH 03/34] Delete deploy, restart, health and start/stop scripts for other services Odin does not deploy, restart or health-probe anything: Idunn owns deployment and daemon survival, and every service in this set is either Idunn-deployed from its own repo recipe or archived. Removed from scripts/: deploy-yggdrasil-{heimdall,repixelizer,source-app,streampixels} (.ps1/.cmd, armed behind IDUNN_ACTUATOR=1) and the linux heimdall deploy; restart-* (odin, stonks, vili, weksa, nightwing eve dashboard and browser reference; several already exit 2 or throw as archived); health-* (odin, voidbot, stonks, vili, weksa, mimir, nightwing, raven capture mux, yggdrasil heimdall/repixelizer/streampixels/source-app; archived stubs and orphans); start-odin.ps1 and stop-odin.ps1 (launched the deleted Node coordinator). Kept: write-gjallar-persona-cc.mjs (persona tooling, package.json gjallar scripts) and the nightwing usb-move helpers, wasapi loopback, firewall and wireguard scripts, none of which is a lifecycle actuator. Co-Authored-By: Claude Sonnet 5.5 --- scripts/deploy-yggdrasil-heimdall.cmd | 10 - scripts/deploy-yggdrasil-heimdall.ps1 | 156 -------- scripts/deploy-yggdrasil-repixelizer.cmd | 10 - scripts/deploy-yggdrasil-repixelizer.ps1 | 161 --------- scripts/deploy-yggdrasil-source-app.ps1 | 137 ------- scripts/deploy-yggdrasil-streampixels.cmd | 10 - scripts/deploy-yggdrasil-streampixels.ps1 | 156 -------- scripts/health-mimir-eve-dashboard.cmd | 3 - ...health-nightwing-eve-browser-reference.cmd | 3 - ...health-nightwing-eve-browser-reference.ps1 | 335 ------------------ scripts/health-nightwing-eve-dashboard.cmd | 3 - scripts/health-nightwing-eve-dashboard.ps1 | 1 - scripts/health-odin.cmd | 3 - scripts/health-odin.ps1 | 68 ---- scripts/health-raven-capture-mux.cmd | 3 - scripts/health-raven-capture-mux.ps1 | 2 - scripts/health-stonks.cmd | 3 - scripts/health-vili.cmd | 3 - scripts/health-voidbot.cmd | 3 - scripts/health-voidbot.ps1 | 181 ---------- scripts/health-weksa.cmd | 3 - scripts/health-yggdrasil-heimdall.cmd | 3 - scripts/health-yggdrasil-repixelizer.cmd | 3 - scripts/health-yggdrasil-source-app.ps1 | 43 --- scripts/health-yggdrasil-streampixels.cmd | 3 - scripts/linux/deploy-yggdrasil-heimdall | 77 ---- ...estart-nightwing-eve-browser-reference.cmd | 10 - ...estart-nightwing-eve-browser-reference.ps1 | 17 - scripts/restart-nightwing-eve-dashboard.cmd | 3 - scripts/restart-nightwing-eve-dashboard.ps1 | 1 - scripts/restart-odin.cmd | 10 - scripts/restart-odin.ps1 | 46 --- scripts/restart-stonks.cmd | 10 - scripts/restart-stonks.ps1 | 21 -- scripts/restart-vili.cmd | 10 - scripts/restart-vili.ps1 | 287 --------------- scripts/restart-weksa.cmd | 10 - scripts/start-odin.ps1 | 68 ---- scripts/stop-odin.ps1 | 22 -- 39 files changed, 1898 deletions(-) delete mode 100644 scripts/deploy-yggdrasil-heimdall.cmd delete mode 100644 scripts/deploy-yggdrasil-heimdall.ps1 delete mode 100644 scripts/deploy-yggdrasil-repixelizer.cmd delete mode 100644 scripts/deploy-yggdrasil-repixelizer.ps1 delete mode 100644 scripts/deploy-yggdrasil-source-app.ps1 delete mode 100644 scripts/deploy-yggdrasil-streampixels.cmd delete mode 100644 scripts/deploy-yggdrasil-streampixels.ps1 delete mode 100644 scripts/health-mimir-eve-dashboard.cmd delete mode 100644 scripts/health-nightwing-eve-browser-reference.cmd delete mode 100644 scripts/health-nightwing-eve-browser-reference.ps1 delete mode 100644 scripts/health-nightwing-eve-dashboard.cmd delete mode 100644 scripts/health-nightwing-eve-dashboard.ps1 delete mode 100644 scripts/health-odin.cmd delete mode 100644 scripts/health-odin.ps1 delete mode 100644 scripts/health-raven-capture-mux.cmd delete mode 100644 scripts/health-raven-capture-mux.ps1 delete mode 100644 scripts/health-stonks.cmd delete mode 100644 scripts/health-vili.cmd delete mode 100644 scripts/health-voidbot.cmd delete mode 100644 scripts/health-voidbot.ps1 delete mode 100644 scripts/health-weksa.cmd delete mode 100644 scripts/health-yggdrasil-heimdall.cmd delete mode 100644 scripts/health-yggdrasil-repixelizer.cmd delete mode 100644 scripts/health-yggdrasil-source-app.ps1 delete mode 100644 scripts/health-yggdrasil-streampixels.cmd delete mode 100755 scripts/linux/deploy-yggdrasil-heimdall delete mode 100644 scripts/restart-nightwing-eve-browser-reference.cmd delete mode 100644 scripts/restart-nightwing-eve-browser-reference.ps1 delete mode 100644 scripts/restart-nightwing-eve-dashboard.cmd delete mode 100644 scripts/restart-nightwing-eve-dashboard.ps1 delete mode 100644 scripts/restart-odin.cmd delete mode 100644 scripts/restart-odin.ps1 delete mode 100644 scripts/restart-stonks.cmd delete mode 100644 scripts/restart-stonks.ps1 delete mode 100644 scripts/restart-vili.cmd delete mode 100644 scripts/restart-vili.ps1 delete mode 100644 scripts/restart-weksa.cmd delete mode 100644 scripts/start-odin.ps1 delete mode 100644 scripts/stop-odin.ps1 diff --git a/scripts/deploy-yggdrasil-heimdall.cmd b/scripts/deploy-yggdrasil-heimdall.cmd deleted file mode 100644 index d15d7b0..0000000 --- a/scripts/deploy-yggdrasil-heimdall.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually. - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually. - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0deploy-yggdrasil-heimdall.ps1 diff --git a/scripts/deploy-yggdrasil-heimdall.ps1 b/scripts/deploy-yggdrasil-heimdall.ps1 deleted file mode 100644 index f7d46cc..0000000 --- a/scripts/deploy-yggdrasil-heimdall.ps1 +++ /dev/null @@ -1,156 +0,0 @@ -param( - [string] $SshTarget = "yggdrasil", - [string] $RepoRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Heimdall"), - [string] $CultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"), - [string] $AppUser = "heimdall", - [string] $RemoteAppHome = "/srv/heimdall", - [string] $RemoteTarballName = "heimdall-source.tar", - [string] $RemoteCultLibTarballName = "heimdall-cultlib.tar", - [string] $DeployScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\deploy-heimdall.sh"), - [string] $CheckScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\check-heimdall.sh"), - [string] $UpstreamRemote = "origin", - [string] $UpstreamBranch = "main", - [string] $CultLibUpstreamRemote = "origin", - [string] $CultLibUpstreamBranch = "main" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually." -} - -foreach ($path in @($RepoRoot, $CultLibRoot, $DeployScript, $CheckScript)) { - if (-not (Test-Path -LiteralPath $path)) { - throw "Required path not found: $path" - } -} - -$sourceRef = "$UpstreamRemote/$UpstreamBranch" -$cultLibSourceRef = "$CultLibUpstreamRemote/$CultLibUpstreamBranch" -git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch -if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $RepoRoot $sourceRef" } -git -C $CultLibRoot fetch --prune $CultLibUpstreamRemote $CultLibUpstreamBranch -if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $CultLibRoot $cultLibSourceRef" } - -$heimdallCommit = (git -C $RepoRoot rev-parse $sourceRef).Trim() -if ([string]::IsNullOrWhiteSpace($heimdallCommit)) { - throw "Could not determine Heimdall git revision." -} -$cultLibCommit = (git -C $CultLibRoot rev-parse $cultLibSourceRef).Trim() -if ([string]::IsNullOrWhiteSpace($cultLibCommit)) { - throw "Could not determine CultLib git revision." -} - -$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") "yggdrasil-heimdall" -$sourceTarPath = Join-Path $scratch $RemoteTarballName -$cultLibTarPath = Join-Path $scratch $RemoteCultLibTarballName -$manifestPath = Join-Path $scratch "deployment-manifest.txt" -New-Item -ItemType Directory -Force -Path $scratch | Out-Null - -git -C $RepoRoot archive --format=tar --output=$sourceTarPath $sourceRef -if ($LASTEXITCODE -ne 0) { - throw "git archive failed for $RepoRoot $sourceRef" -} - -if (Test-Path -LiteralPath $cultLibTarPath) { - Remove-Item -LiteralPath $cultLibTarPath -Force -} -git -C $CultLibRoot archive --format=tar --output=$cultLibTarPath $cultLibSourceRef packages/cultnet-ts packages/cultcache-ts -if ($LASTEXITCODE -ne 0) { - throw "CultLib tar build failed for $CultLibRoot $cultLibSourceRef" -} - -$sourceHash = (Get-FileHash $sourceTarPath -Algorithm SHA256).Hash.ToLowerInvariant() -$cultLibHash = (Get-FileHash $cultLibTarPath -Algorithm SHA256).Hash.ToLowerInvariant() -$deployedAt = [DateTimeOffset]::UtcNow.ToString("O") -@( - "schema=gamecult.idunn.deployment_manifest.v1" - "appId=yggdrasil-heimdall" - "repoRoot=$RepoRoot" - "upstreamRemote=$UpstreamRemote" - "upstreamBranch=$UpstreamBranch" - "sourceRef=$sourceRef" - "gitCommit=$heimdallCommit" - "artifact=$RemoteTarballName" - "sha256=$sourceHash" - "cultLibRepoRoot=$CultLibRoot" - "cultLibUpstreamRemote=$CultLibUpstreamRemote" - "cultLibUpstreamBranch=$CultLibUpstreamBranch" - "cultLibSourceRef=$cultLibSourceRef" - "cultLibGitCommit=$cultLibCommit" - "cultLibArtifact=$RemoteCultLibTarballName" - "cultLibSha256=$cultLibHash" - "deployedAtUtc=$deployedAt" -) | Set-Content -Encoding ASCII -LiteralPath $manifestPath - -$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))" -$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))" -$remoteSourceTarball = "/home/gamecultadmin/$RemoteTarballName" -$remoteCultLibTarball = "/home/gamecultadmin/$RemoteCultLibTarballName" -$remoteManifest = "/home/gamecultadmin/yggdrasil-heimdall-deployment-manifest.txt" -$remoteRunner = "/home/gamecultadmin/run-yggdrasil-heimdall-deploy.sh" -$targetSourceTarball = "$RemoteAppHome/$RemoteTarballName" -$targetCultLibTarball = "$RemoteAppHome/$RemoteCultLibTarballName" -$targetManifest = "$RemoteAppHome/deployment-manifest.txt" - -$sftpBatchPath = Join-Path $scratch "yggdrasil-heimdall-upload.sftp" -$runnerPath = Join-Path $scratch "run-yggdrasil-heimdall-deploy.sh" -$runnerScript = @" -#!/usr/bin/env bash -set -euo pipefail - -sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome' -sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteSourceTarball' '$targetSourceTarball' -sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteCultLibTarball' '$targetCultLibTarball' -sudo -n install -m 644 '$remoteManifest' '$targetManifest' -chmod +x '$remoteDeploy' '$remoteCheck' -sudo -n env GAMECULT_IDUNN_REMOTE_ACTUATOR=1 IDUNN_COMMAND_AUTHORITY=idunn-daemon bash '$remoteDeploy' - -check_ok=0 -for i in `$(seq 1 30); do - if sudo -n bash '$remoteCheck'; then - check_ok=1 - break - fi - sleep 2 -done - -if [ "`$check_ok" -ne 1 ]; then - sudo -n bash '$remoteCheck' - exit 1 -fi -"@.Replace("`r`n", "`n") -[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII) -@( - "put ""$sourceTarPath"" ""$remoteSourceTarball""" - "put ""$cultLibTarPath"" ""$remoteCultLibTarball""" - "put ""$DeployScript"" ""$remoteDeploy""" - "put ""$CheckScript"" ""$remoteCheck""" - "put ""$manifestPath"" ""$remoteManifest""" - "put ""$runnerPath"" ""$remoteRunner""" -) | Set-Content -Encoding ASCII -LiteralPath $sftpBatchPath - -try { - & sftp.exe -b $sftpBatchPath $SshTarget - if ($LASTEXITCODE -ne 0) { - throw "sftp upload failed for yggdrasil-heimdall" - } - - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteRunner' && bash '$remoteRunner'" - $remoteExit = $LASTEXITCODE - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteRunner'" - if ($remoteExit -ne 0) { - throw "remote deploy/check failed for yggdrasil-heimdall" - } -} -finally { - Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $sftpBatchPath -Force -ErrorAction SilentlyContinue -} - -Write-Host "yggdrasil-heimdall deployed with CultLib snapshot." -Write-Host " heimdallCommit=$heimdallCommit" -Write-Host " cultLibCommit=$cultLibCommit" -Write-Host " artifactSha256=$sourceHash" -Write-Host " cultLibSha256=$cultLibHash" diff --git a/scripts/deploy-yggdrasil-repixelizer.cmd b/scripts/deploy-yggdrasil-repixelizer.cmd deleted file mode 100644 index ec0edcc..0000000 --- a/scripts/deploy-yggdrasil-repixelizer.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually. - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually. - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0deploy-yggdrasil-repixelizer.ps1 diff --git a/scripts/deploy-yggdrasil-repixelizer.ps1 b/scripts/deploy-yggdrasil-repixelizer.ps1 deleted file mode 100644 index d25bbd1..0000000 --- a/scripts/deploy-yggdrasil-repixelizer.ps1 +++ /dev/null @@ -1,161 +0,0 @@ -param( - [string] $SshTarget = "yggdrasil", - [string] $RepoRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "repixelizer"), - [string] $CultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"), - [string] $AppUser = "repixelizer", - [string] $RemoteAppHome = "/srv/repixelizer", - [string] $RemoteTarballName = "repixelizer-source.tar", - [string] $RemoteCultLibTarballName = "repixelizer-cultlib.tar", - [string] $DeployScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\deploy-repixelizer-gui.sh"), - [string] $CheckScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\check-repixelizer-gui.sh"), - [string] $UpstreamRemote = "origin", - [string] $UpstreamBranch = "main", - [string] $CultLibUpstreamRemote = "origin", - [string] $CultLibUpstreamBranch = "main" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually." -} - -foreach ($path in @($RepoRoot, $CultLibRoot, $DeployScript, $CheckScript)) { - if (-not (Test-Path -LiteralPath $path)) { - throw "Required path not found: $path" - } -} - -$sourceRef = "$UpstreamRemote/$UpstreamBranch" -$cultLibSourceRef = "$CultLibUpstreamRemote/$CultLibUpstreamBranch" -git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch -if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $RepoRoot $sourceRef" } -git -C $CultLibRoot fetch --prune $CultLibUpstreamRemote $CultLibUpstreamBranch -if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $CultLibRoot $cultLibSourceRef" } - -$repixelizerCommit = (git -C $RepoRoot rev-parse $sourceRef).Trim() -if ([string]::IsNullOrWhiteSpace($repixelizerCommit)) { - throw "Could not determine Repixelizer git revision." -} - -$cultLibCommit = (git -C $CultLibRoot rev-parse $cultLibSourceRef).Trim() -if ([string]::IsNullOrWhiteSpace($cultLibCommit)) { - throw "Could not determine CultLib git revision." -} - -$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") "yggdrasil-repixelizer" -$sourceTarPath = Join-Path $scratch $RemoteTarballName -$cultLibTarPath = Join-Path $scratch $RemoteCultLibTarballName -$manifestPath = Join-Path $scratch "deployment-manifest.txt" -New-Item -ItemType Directory -Force -Path $scratch | Out-Null - -git -C $RepoRoot archive --format=tar --output=$sourceTarPath $sourceRef -if ($LASTEXITCODE -ne 0) { - throw "git archive failed for $RepoRoot $sourceRef" -} - -if (Test-Path -LiteralPath $cultLibTarPath) { - Remove-Item -LiteralPath $cultLibTarPath -Force -} -# All three Python packages. CultLib split packages/cultcache-py into -# cultcache-py / cultnet-py / cultmesh-py; module names did not change, so -# archiving only the first still succeeds and ships an incomplete library -# that fails later as an ImportError, far from this line. -git -C $CultLibRoot archive --format=tar --output=$cultLibTarPath $cultLibSourceRef packages/cultcache-py packages/cultnet-py packages/cultmesh-py -if ($LASTEXITCODE -ne 0) { - throw "CultLib tar build failed for $CultLibRoot $cultLibSourceRef" -} - -$sourceHash = (Get-FileHash $sourceTarPath -Algorithm SHA256).Hash.ToLowerInvariant() -$cultLibHash = (Get-FileHash $cultLibTarPath -Algorithm SHA256).Hash.ToLowerInvariant() -$deployedAt = [DateTimeOffset]::UtcNow.ToString("O") -@( - "schema=gamecult.idunn.deployment_manifest.v1" - "appId=yggdrasil-repixelizer" - "repoRoot=$RepoRoot" - "upstreamRemote=$UpstreamRemote" - "upstreamBranch=$UpstreamBranch" - "sourceRef=$sourceRef" - "gitCommit=$repixelizerCommit" - "artifact=$RemoteTarballName" - "sha256=$sourceHash" - "cultLibRepoRoot=$CultLibRoot" - "cultLibUpstreamRemote=$CultLibUpstreamRemote" - "cultLibUpstreamBranch=$CultLibUpstreamBranch" - "cultLibSourceRef=$cultLibSourceRef" - "cultLibGitCommit=$cultLibCommit" - "cultLibArtifact=$RemoteCultLibTarballName" - "cultLibSha256=$cultLibHash" - "deployedAtUtc=$deployedAt" -) | Set-Content -Encoding ASCII -LiteralPath $manifestPath - -$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))" -$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))" -$remoteSourceTarball = "/home/gamecultadmin/$RemoteTarballName" -$remoteCultLibTarball = "/home/gamecultadmin/$RemoteCultLibTarballName" -$remoteManifest = "/home/gamecultadmin/yggdrasil-repixelizer-deployment-manifest.txt" -$remoteRunner = "/home/gamecultadmin/run-yggdrasil-repixelizer-deploy.sh" -$targetSourceTarball = "$RemoteAppHome/$RemoteTarballName" -$targetCultLibTarball = "$RemoteAppHome/$RemoteCultLibTarballName" -$targetManifest = "$RemoteAppHome/deployment-manifest.txt" - -$sftpBatchPath = Join-Path $scratch "yggdrasil-repixelizer-upload.sftp" -$runnerPath = Join-Path $scratch "run-yggdrasil-repixelizer-deploy.sh" -$runnerScript = @" -#!/usr/bin/env bash -set -euo pipefail - -sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome' -sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteSourceTarball' '$targetSourceTarball' -sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteCultLibTarball' '$targetCultLibTarball' -sudo -n install -m 644 '$remoteManifest' '$targetManifest' -chmod +x '$remoteDeploy' '$remoteCheck' -sudo -n env GAMECULT_IDUNN_REMOTE_ACTUATOR=1 IDUNN_COMMAND_AUTHORITY=idunn-daemon bash '$remoteDeploy' - -check_ok=0 -for i in `$(seq 1 30); do - if sudo -n bash '$remoteCheck'; then - check_ok=1 - break - fi - sleep 2 -done - -if [ "`$check_ok" -ne 1 ]; then - sudo -n bash '$remoteCheck' - exit 1 -fi -"@.Replace("`r`n", "`n") -[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII) -@( - "put ""$sourceTarPath"" ""$remoteSourceTarball""" - "put ""$cultLibTarPath"" ""$remoteCultLibTarball""" - "put ""$DeployScript"" ""$remoteDeploy""" - "put ""$CheckScript"" ""$remoteCheck""" - "put ""$manifestPath"" ""$remoteManifest""" - "put ""$runnerPath"" ""$remoteRunner""" -) | Set-Content -Encoding ASCII -LiteralPath $sftpBatchPath - -try { - & sftp.exe -b $sftpBatchPath $SshTarget - if ($LASTEXITCODE -ne 0) { - throw "sftp upload failed for yggdrasil-repixelizer" - } - - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteRunner' && bash '$remoteRunner'" - $remoteExit = $LASTEXITCODE - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteRunner'" - if ($remoteExit -ne 0) { - throw "remote deploy/check failed for yggdrasil-repixelizer" - } -} -finally { - Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $sftpBatchPath -Force -ErrorAction SilentlyContinue -} - -Write-Host "yggdrasil-repixelizer deployed with CultLib snapshot." -Write-Host " repixelizerCommit=$repixelizerCommit" -Write-Host " cultLibCommit=$cultLibCommit" -Write-Host " artifactSha256=$sourceHash" -Write-Host " cultLibSha256=$cultLibHash" diff --git a/scripts/deploy-yggdrasil-source-app.ps1 b/scripts/deploy-yggdrasil-source-app.ps1 deleted file mode 100644 index 15408f2..0000000 --- a/scripts/deploy-yggdrasil-source-app.ps1 +++ /dev/null @@ -1,137 +0,0 @@ -param( - [Parameter(Mandatory = $true)] [string] $AppId, - [Parameter(Mandatory = $true)] [string] $RepoRoot, - [Parameter(Mandatory = $true)] [string] $AppUser, - [Parameter(Mandatory = $true)] [string] $RemoteAppHome, - [Parameter(Mandatory = $true)] [string] $RemoteTarballName, - [Parameter(Mandatory = $true)] [string] $DeployScript, - [Parameter(Mandatory = $true)] [string] $CheckScript, - [string] $SshTarget = "yggdrasil", - [string] $UpstreamRemote = "origin", - [string] $UpstreamBranch = "main", - [string] $StateMigrationScript = "" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1") { - throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually." -} - -if (-not (Test-Path -LiteralPath $RepoRoot)) { - throw "Repo root not found: $RepoRoot" -} -if (-not (Test-Path -LiteralPath $DeployScript)) { - throw "Deploy script not found: $DeployScript" -} -if (-not (Test-Path -LiteralPath $CheckScript)) { - throw "Check script not found: $CheckScript" -} -if (-not [string]::IsNullOrWhiteSpace($StateMigrationScript) -and -not (Test-Path -LiteralPath $StateMigrationScript)) { - throw "State migration script not found: $StateMigrationScript" -} - -git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch -if ($LASTEXITCODE -ne 0) { - throw "git fetch failed for $RepoRoot $UpstreamRemote/$UpstreamBranch" -} - -$sourceRef = "$UpstreamRemote/$UpstreamBranch" -$commit = (git -C $RepoRoot rev-parse $sourceRef).Trim() -if ([string]::IsNullOrWhiteSpace($commit)) { - throw "Could not determine git revision for $RepoRoot $sourceRef" -} - -$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") $AppId -$tarPath = Join-Path $scratch $RemoteTarballName -$manifestPath = Join-Path $scratch "deployment-manifest.txt" -New-Item -ItemType Directory -Force -Path $scratch | Out-Null - -git -C $RepoRoot archive --format=tar --output=$tarPath $sourceRef -if ($LASTEXITCODE -ne 0) { - throw "git archive failed for $RepoRoot $sourceRef" -} - -$hash = (Get-FileHash $tarPath -Algorithm SHA256).Hash.ToLowerInvariant() -$deployedAt = [DateTimeOffset]::UtcNow.ToString("O") -@( - "schema=gamecult.idunn.deployment_manifest.v1" - "appId=$AppId" - "repoRoot=$RepoRoot" - "upstreamRemote=$UpstreamRemote" - "upstreamBranch=$UpstreamBranch" - "sourceRef=$sourceRef" - "gitCommit=$commit" - "artifact=$RemoteTarballName" - "sha256=$hash" - "deployedAtUtc=$deployedAt" -) | Set-Content -Encoding ASCII -LiteralPath $manifestPath - -$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))" -$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))" -$remoteMigration = if ([string]::IsNullOrWhiteSpace($StateMigrationScript)) { "" } else { "/home/gamecultadmin/$([IO.Path]::GetFileName($StateMigrationScript))" } -$remoteTarball = "/home/gamecultadmin/$RemoteTarballName" -$remoteManifest = "/home/gamecultadmin/$AppId-deployment-manifest.txt" -$remoteRunner = "/home/gamecultadmin/run-$AppId-deploy.sh" -$targetTarball = "$RemoteAppHome/$RemoteTarballName" -$targetManifest = "$RemoteAppHome/deployment-manifest.txt" -$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10", $SshTarget) -$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10") -$runnerPath = Join-Path $scratch "run-$AppId-deploy.sh" - -$runnerScript = @" -#!/usr/bin/env bash -set -euo pipefail - -sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome' -sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteTarball' '$targetTarball' -sudo -n install -m 644 '$remoteManifest' '$targetManifest' -chmod +x '$remoteDeploy' '$remoteCheck' -if [ -n '$remoteMigration' ]; then - chmod +x '$remoteMigration' - sudo -n bash '$remoteMigration' -fi -sudo -n bash '$remoteDeploy' - -check_ok=0 -for i in `$(seq 1 30); do - if sudo -n bash '$remoteCheck'; then - check_ok=1 - break - fi - sleep 2 -done - -if [ "`$check_ok" -ne 1 ]; then - sudo -n bash '$remoteCheck' - exit 1 -fi -"@.Replace("`r`n", "`n") -[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII) - -scp.exe @scpArgs $tarPath "${SshTarget}:$remoteTarball" -if ($LASTEXITCODE -ne 0) { throw "scp source tarball failed for $AppId" } -scp.exe @scpArgs $DeployScript "${SshTarget}:$remoteDeploy" -if ($LASTEXITCODE -ne 0) { throw "scp deploy script failed for $AppId" } -scp.exe @scpArgs $CheckScript "${SshTarget}:$remoteCheck" -if ($LASTEXITCODE -ne 0) { throw "scp check script failed for $AppId" } -if (-not [string]::IsNullOrWhiteSpace($StateMigrationScript)) { - scp.exe @scpArgs $StateMigrationScript "${SshTarget}:$remoteMigration" - if ($LASTEXITCODE -ne 0) { throw "scp state migration script failed for $AppId" } -} -scp.exe @scpArgs $manifestPath "${SshTarget}:$remoteManifest" -if ($LASTEXITCODE -ne 0) { throw "scp manifest failed for $AppId" } -scp.exe @scpArgs $runnerPath "${SshTarget}:$remoteRunner" -if ($LASTEXITCODE -ne 0) { throw "scp runner script failed for $AppId" } - -ssh.exe @sshArgs "chmod +x '$remoteRunner' && bash '$remoteRunner'" -$remoteExit = $LASTEXITCODE -ssh.exe @sshArgs "rm -f '$remoteRunner'" -if ($remoteExit -ne 0) { - throw "remote deploy/check failed for $AppId" -} - -Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue - -Write-Host "$AppId deployed to Yggdrasil from $sourceRef at $commit." -Write-Host " artifactSha256=$hash" diff --git a/scripts/deploy-yggdrasil-streampixels.cmd b/scripts/deploy-yggdrasil-streampixels.cmd deleted file mode 100644 index 7c05142..0000000 --- a/scripts/deploy-yggdrasil-streampixels.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually. - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually. - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0deploy-yggdrasil-streampixels.ps1 diff --git a/scripts/deploy-yggdrasil-streampixels.ps1 b/scripts/deploy-yggdrasil-streampixels.ps1 deleted file mode 100644 index d275abf..0000000 --- a/scripts/deploy-yggdrasil-streampixels.ps1 +++ /dev/null @@ -1,156 +0,0 @@ -param( - [string] $SshTarget = "yggdrasil", - [string] $RepoRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "StreamPixels"), - [string] $CultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"), - [string] $AppUser = "streampixels", - [string] $RemoteAppHome = "/srv/streampixels", - [string] $RemoteTarballName = "streampixels-source.tar", - [string] $RemoteCultLibTarballName = "streampixels-cultlib.tar", - [string] $DeployScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\deploy-streampixels-preview.sh"), - [string] $CheckScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\check-streampixels-preview.sh"), - [string] $UpstreamRemote = "origin", - [string] $UpstreamBranch = "main", - [string] $CultLibUpstreamRemote = "origin", - [string] $CultLibUpstreamBranch = "main" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually." -} - -foreach ($path in @($RepoRoot, $CultLibRoot, $DeployScript, $CheckScript)) { - if (-not (Test-Path -LiteralPath $path)) { - throw "Required path not found: $path" - } -} - -$sourceRef = "$UpstreamRemote/$UpstreamBranch" -$cultLibSourceRef = "$CultLibUpstreamRemote/$CultLibUpstreamBranch" -git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch -if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $RepoRoot $sourceRef" } -git -C $CultLibRoot fetch --prune $CultLibUpstreamRemote $CultLibUpstreamBranch -if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $CultLibRoot $cultLibSourceRef" } - -$streamPixelsCommit = (git -C $RepoRoot rev-parse $sourceRef).Trim() -if ([string]::IsNullOrWhiteSpace($streamPixelsCommit)) { - throw "Could not determine StreamPixels git revision." -} -$cultLibCommit = (git -C $CultLibRoot rev-parse $cultLibSourceRef).Trim() -if ([string]::IsNullOrWhiteSpace($cultLibCommit)) { - throw "Could not determine CultLib git revision." -} - -$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") "yggdrasil-streampixels" -$sourceTarPath = Join-Path $scratch $RemoteTarballName -$cultLibTarPath = Join-Path $scratch $RemoteCultLibTarballName -$manifestPath = Join-Path $scratch "deployment-manifest.txt" -New-Item -ItemType Directory -Force -Path $scratch | Out-Null - -git -C $RepoRoot archive --format=tar --output=$sourceTarPath $sourceRef -if ($LASTEXITCODE -ne 0) { - throw "git archive failed for $RepoRoot $sourceRef" -} - -if (Test-Path -LiteralPath $cultLibTarPath) { - Remove-Item -LiteralPath $cultLibTarPath -Force -} -git -C $CultLibRoot archive --format=tar --output=$cultLibTarPath $cultLibSourceRef package.json package-lock.json scripts/run-typescript-workspaces.mjs contracts/cultnet packages/cultcache-ts packages/cultnet-ts packages/cultmesh-ts packages/cultmesh-browser -if ($LASTEXITCODE -ne 0) { - throw "CultLib tar build failed for $CultLibRoot $cultLibSourceRef" -} - -$sourceHash = (Get-FileHash $sourceTarPath -Algorithm SHA256).Hash.ToLowerInvariant() -$cultLibHash = (Get-FileHash $cultLibTarPath -Algorithm SHA256).Hash.ToLowerInvariant() -$deployedAt = [DateTimeOffset]::UtcNow.ToString("O") -@( - "schema=gamecult.idunn.deployment_manifest.v1" - "appId=yggdrasil-streampixels" - "repoRoot=$RepoRoot" - "upstreamRemote=$UpstreamRemote" - "upstreamBranch=$UpstreamBranch" - "sourceRef=$sourceRef" - "gitCommit=$streamPixelsCommit" - "artifact=$RemoteTarballName" - "sha256=$sourceHash" - "cultLibRepoRoot=$CultLibRoot" - "cultLibUpstreamRemote=$CultLibUpstreamRemote" - "cultLibUpstreamBranch=$CultLibUpstreamBranch" - "cultLibSourceRef=$cultLibSourceRef" - "cultLibGitCommit=$cultLibCommit" - "cultLibArtifact=$RemoteCultLibTarballName" - "cultLibSha256=$cultLibHash" - "deployedAtUtc=$deployedAt" -) | Set-Content -Encoding ASCII -LiteralPath $manifestPath - -$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))" -$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))" -$remoteSourceTarball = "/home/gamecultadmin/$RemoteTarballName" -$remoteCultLibTarball = "/home/gamecultadmin/$RemoteCultLibTarballName" -$remoteManifest = "/home/gamecultadmin/yggdrasil-streampixels-deployment-manifest.txt" -$remoteRunner = "/home/gamecultadmin/run-yggdrasil-streampixels-deploy.sh" -$targetSourceTarball = "$RemoteAppHome/$RemoteTarballName" -$targetCultLibTarball = "$RemoteAppHome/$RemoteCultLibTarballName" -$targetManifest = "$RemoteAppHome/deployment-manifest.txt" - -$sftpBatchPath = Join-Path $scratch "yggdrasil-streampixels-upload.sftp" -$runnerPath = Join-Path $scratch "run-yggdrasil-streampixels-deploy.sh" -$runnerScript = @" -#!/usr/bin/env bash -set -euo pipefail - -sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome' -sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteSourceTarball' '$targetSourceTarball' -sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteCultLibTarball' '$targetCultLibTarball' -sudo -n install -m 644 '$remoteManifest' '$targetManifest' -chmod +x '$remoteDeploy' '$remoteCheck' -sudo -n env GAMECULT_IDUNN_REMOTE_ACTUATOR=1 IDUNN_COMMAND_AUTHORITY=idunn-daemon bash '$remoteDeploy' - -check_ok=0 -for i in `$(seq 1 30); do - if sudo -n bash '$remoteCheck'; then - check_ok=1 - break - fi - sleep 2 -done - -if [ "`$check_ok" -ne 1 ]; then - sudo -n bash '$remoteCheck' - exit 1 -fi -"@.Replace("`r`n", "`n") -[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII) -@( - "put ""$sourceTarPath"" ""$remoteSourceTarball""" - "put ""$cultLibTarPath"" ""$remoteCultLibTarball""" - "put ""$DeployScript"" ""$remoteDeploy""" - "put ""$CheckScript"" ""$remoteCheck""" - "put ""$manifestPath"" ""$remoteManifest""" - "put ""$runnerPath"" ""$remoteRunner""" -) | Set-Content -Encoding ASCII -LiteralPath $sftpBatchPath - -try { - & sftp.exe -b $sftpBatchPath $SshTarget - if ($LASTEXITCODE -ne 0) { - throw "sftp upload failed for yggdrasil-streampixels" - } - - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteRunner' && bash '$remoteRunner'" - $remoteExit = $LASTEXITCODE - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteRunner'" - if ($remoteExit -ne 0) { - throw "remote deploy/check failed for yggdrasil-streampixels" - } -} -finally { - Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $sftpBatchPath -Force -ErrorAction SilentlyContinue -} - -Write-Host "yggdrasil-streampixels deployed with CultLib snapshot." -Write-Host " streamPixelsCommit=$streamPixelsCommit" -Write-Host " cultLibCommit=$cultLibCommit" -Write-Host " artifactSha256=$sourceHash" -Write-Host " cultLibSha256=$cultLibHash" diff --git a/scripts/health-mimir-eve-dashboard.cmd b/scripts/health-mimir-eve-dashboard.cmd deleted file mode 100644 index aaea10f..0000000 --- a/scripts/health-mimir-eve-dashboard.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-mimir-eve-dashboard.cmd archived: Mimir Eve dashboard health is daemon-published over CultNet/RUDP as mimir.cultnet-rudp-provider-health. Do not probe localhost HTTP for daemon health. 1>&2 -exit /b 2 diff --git a/scripts/health-nightwing-eve-browser-reference.cmd b/scripts/health-nightwing-eve-browser-reference.cmd deleted file mode 100644 index 1a4b44e..0000000 --- a/scripts/health-nightwing-eve-browser-reference.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-nightwing-eve-browser-reference.cmd archived: Nightwing Eve browser reference health is daemon-published over CultNet/RUDP as nightwing.cultnet-rudp-browser-reference-health. Use health-nightwing-eve-browser-reference.ps1 only as an explicit witness inspection, not Idunn health truth. -exit /b 2 diff --git a/scripts/health-nightwing-eve-browser-reference.ps1 b/scripts/health-nightwing-eve-browser-reference.ps1 deleted file mode 100644 index e4bd6ab..0000000 --- a/scripts/health-nightwing-eve-browser-reference.ps1 +++ /dev/null @@ -1,335 +0,0 @@ -param( - [string] $SshTarget = "nightwing", - [string] $ExpectedDocumentRoot = "/opt/gamecult/eve-browser-reference", - [string] $RemoteCultCacheWitness = "/var/lib/gamecult/eve-browser-reference/cultcache/eve-browser-reference.service.cc", - [int] $MaxWitnessAgeSeconds = 120 -) - -$ErrorActionPreference = "Stop" - -function Fail-IdunnHealth { - param( - [string] $State, - [string] $Message - ) - - Write-Output "idunn.health.state=$State" - throw $Message -} - -function Invoke-SshText { - param( - [string] $Command, - [string] $FailureMessage, - [string] $FailureState = "failed" - ) - - $output = ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $SshTarget $Command - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($output)) { - Fail-IdunnHealth -State $FailureState -Message $FailureMessage - } - - return $output -} - -function Get-CultCacheInspection { - param( - [string] $WitnessCopyPath, - [string] $InspectorPackageJson - ) - - $nodeScript = @' -const fs = require("fs"); -const { createRequire } = require("module"); - -const witnessCopyPath = process.argv[2]; -const inspectorPackageJson = process.argv[3]; -const requireCultCacheInspection = createRequire(inspectorPackageJson); -const { inspectCultCacheBytes } = requireCultCacheInspection("cultcache-ts"); -const inspection = inspectCultCacheBytes(witnessCopyPath, fs.readFileSync(witnessCopyPath)); - -function firstRecord(schemaName) { - return inspection.records.find((record) => record.schemaName === schemaName) ?? null; -} - -function previewArray(record) { - return Array.isArray(record?.payloadPreview) ? record.payloadPreview : []; -} - -function normalizeRecord(schemaName) { - const record = firstRecord(schemaName); - if (!record) { - return null; - } - - const preview = previewArray(record); - switch (schemaName) { - case "mimir.eve_browser_reference_manifest": - return { - schemaName: record.schemaName, - storedAt: record.storedAt, - daemonId: preview[0] ?? "", - siteTitle: preview[1] ?? "", - documentRoot: preview[2] ?? "", - entryPoint: preview[3] ?? "", - port: preview[4] ?? 0, - assetCount: Array.isArray(preview[5]) ? preview[5].length : 0, - surfaceTransport: preview[6] ?? "", - }; - case "mimir.eve_browser_reference_static_surface": - return { - schemaName: record.schemaName, - storedAt: record.storedAt, - daemonId: preview[0] ?? "", - updatedAtUtc: preview[1] ?? "", - documentRoot: preview[2] ?? "", - entryPoint: preview[3] ?? "", - assetCount: Array.isArray(preview[4]) ? preview[4].length : 0, - fixtureCount: Array.isArray(preview[5]) ? preview[5].length : 0, - currentCutLine: preview[6] ?? "", - }; - case "mimir.eve_browser_reference_command_boundary": - return { - schemaName: record.schemaName, - storedAt: record.storedAt, - daemonId: preview[0] ?? "", - updatedAtUtc: preview[1] ?? "", - mode: preview[2] ?? "", - writesAccepted: preview[3] === true, - operatorInputAuthority: preview[4] ?? "", - lifecycleAuthority: preview[5] ?? "", - acceptedCommands: Array.isArray(preview[6]) ? preview[6] : [], - rejectedCommands: Array.isArray(preview[7]) ? preview[7] : [], - }; - case "mimir.eve_browser_reference_transport_profile": - return { - schemaName: record.schemaName, - storedAt: record.storedAt, - daemonId: preview[0] ?? "", - updatedAtUtc: preview[1] ?? "", - currentState: preview[2] ?? "", - inputTransport: preview[3] ?? "", - outputTransport: preview[4] ?? "", - healthContract: preview[5] ?? "", - idunnRudpHealth: preview[6] ?? "", - witnessPath: preview[7] ?? "", - currentCutLine: preview[8] ?? "", - }; - case "idunn.daemon_health": - return { - schemaName: record.schemaName, - storedAt: record.storedAt, - daemonId: preview[0] ?? "", - state: preview[1] ?? "", - detail: preview[2] ?? "", - observedAt: preview[3] ?? "", - healthContract: preview[4] ?? "", - publicationSource: preview[5] ?? "", - transport: preview[6] ?? "", - }; - default: - return { - schemaName: record.schemaName, - storedAt: record.storedAt, - }; - } -} - -console.log(JSON.stringify({ - format: inspection.format, - catalogSchemaVersions: inspection.catalog.map((entry) => entry.schemaVersion), - manifest: normalizeRecord("mimir.eve_browser_reference_manifest"), - staticSurface: normalizeRecord("mimir.eve_browser_reference_static_surface"), - commandBoundary: normalizeRecord("mimir.eve_browser_reference_command_boundary"), - transportProfile: normalizeRecord("mimir.eve_browser_reference_transport_profile"), - daemonHealth: normalizeRecord("idunn.daemon_health"), -})); -'@ - - $inspectionJson = $nodeScript | node - $WitnessCopyPath $InspectorPackageJson - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($inspectionJson)) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness inspection failed." - } - - try { - return $inspectionJson | ConvertFrom-Json - } catch { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache inspection returned malformed JSON." - } -} - -ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $SshTarget "systemctl is-active --quiet nightwing-eve-browser-reference.service" -if ($LASTEXITCODE -ne 0) { - Fail-IdunnHealth -State "failed" -Message "nightwing-eve-browser-reference.service is not active on Nightwing." -} - -$cultCacheStat = Invoke-SshText ` - -Command "stat -c '%s %Y %U %G' '$RemoteCultCacheWitness' 2>/dev/null" ` - -FailureMessage "Nightwing Eve browser reference CultCache witness is missing at $RemoteCultCacheWitness." - -$cultCacheParts = $cultCacheStat.Trim() -split '\s+' -if ($cultCacheParts.Length -lt 4) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness stat output was malformed: $cultCacheStat" -} - -$cultCacheSize = [int64]$cultCacheParts[0] -$cultCacheModifiedUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$cultCacheParts[1]) -$cultCacheOwner = $cultCacheParts[2] -$cultCacheGroup = $cultCacheParts[3] -$cultCacheAgeSeconds = ([DateTimeOffset]::UtcNow - $cultCacheModifiedUtc).TotalSeconds -if ($cultCacheSize -le 0) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness at $RemoteCultCacheWitness is empty." -} - -if ($cultCacheAgeSeconds -gt $MaxWitnessAgeSeconds) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is stale ($([math]::Round($cultCacheAgeSeconds))s old)." -} - -if ($cultCacheOwner -ne "metacrat" -or $cultCacheGroup -ne "metacrat") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference CultCache witness owner is ${cultCacheOwner}:$cultCacheGroup, expected metacrat:metacrat." -} - -try { - $cultCacheInspectorPackageJson = (Resolve-Path (Join-Path $PSScriptRoot "..\..\CultLib\packages\cultcache-ts\package.json")).Path -} catch { - Fail-IdunnHealth -State "failed" -Message "CultCache inspector package is missing from the local Odin body." -} - -$cultCacheWitnessCopy = Join-Path ([System.IO.Path]::GetTempPath()) ("nightwing-eve-browser-reference-" + [Guid]::NewGuid().ToString("N") + ".cc") -try { - $remoteCultCacheBase64 = Invoke-SshText ` - -Command "base64 -w0 '$RemoteCultCacheWitness' 2>/dev/null" ` - -FailureMessage "Nightwing Eve browser reference CultCache witness could not be copied from Nightwing." - [System.IO.File]::WriteAllBytes($cultCacheWitnessCopy, [Convert]::FromBase64String($remoteCultCacheBase64.Trim())) - $inspection = Get-CultCacheInspection -WitnessCopyPath $cultCacheWitnessCopy -InspectorPackageJson $cultCacheInspectorPackageJson -} catch [FormatException] { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness copy was not valid base64." -} finally { - if (Test-Path $cultCacheWitnessCopy) { - Remove-Item $cultCacheWitnessCopy -Force -ErrorAction SilentlyContinue - } -} - -if ($inspection.format -ne "cultcache.store.v1") { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness has unexpected format '$($inspection.format)'." -} - -$requiredCultCacheSchemas = @( - "mimir.eve_browser_reference_manifest.v1", - "mimir.eve_browser_reference_static_surface.v1", - "mimir.eve_browser_reference_command_boundary.v1", - "mimir.eve_browser_reference_transport_profile.v1", - "idunn.daemon_health.v1" -) - -$publishedCultCacheSchemas = @($inspection.catalogSchemaVersions) -foreach ($schema in $requiredCultCacheSchemas) { - if ($publishedCultCacheSchemas -notcontains $schema) { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference CultCache witness is missing schema '$schema'." - } -} - -if ($null -eq $inspection.manifest) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its manifest record." -} - -if ($inspection.manifest.documentRoot -ne $ExpectedDocumentRoot) { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference manifest document root is '$($inspection.manifest.documentRoot)', expected '$ExpectedDocumentRoot'." -} - -if ($inspection.manifest.entryPoint -ne "index.html") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference manifest entry point is '$($inspection.manifest.entryPoint)', expected 'index.html'." -} - -if ($inspection.manifest.surfaceTransport -ne "static-http-lowering") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference manifest transport is '$($inspection.manifest.surfaceTransport)', expected 'static-http-lowering'." -} - -if ($null -eq $inspection.staticSurface) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its static-surface record." -} - -if ($inspection.staticSurface.documentRoot -ne $ExpectedDocumentRoot) { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference static-surface document root is '$($inspection.staticSurface.documentRoot)', expected '$ExpectedDocumentRoot'." -} - -if ($null -eq $inspection.commandBoundary) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its command boundary record." -} - -if ($inspection.commandBoundary.mode -ne "static-read-only-lowering") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference command boundary mode is '$($inspection.commandBoundary.mode)', expected 'static-read-only-lowering'." -} - -if ($inspection.commandBoundary.writesAccepted) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference command boundary unexpectedly accepts writes." -} - -if ($inspection.commandBoundary.lifecycleAuthority -ne "idunn-supervisor-command.restart") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference lifecycle authority is '$($inspection.commandBoundary.lifecycleAuthority)', expected 'idunn-supervisor-command.restart'." -} - -if ($null -eq $inspection.transportProfile) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its transport profile record." -} - -if ($inspection.transportProfile.currentState -ne "partial-rudp-health-and-provider-store-live") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference transport profile state is '$($inspection.transportProfile.currentState)', expected 'partial-rudp-health-and-provider-store-live'." -} - -if ($inspection.transportProfile.inputTransport -ne "http-static-lowering") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference input transport is '$($inspection.transportProfile.inputTransport)', expected 'http-static-lowering'." -} - -if ($inspection.transportProfile.outputTransport -ne "daemon-owned-cultcache-boundary-store + daemon-published-rudp-health") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference output transport is '$($inspection.transportProfile.outputTransport)', expected 'daemon-owned-cultcache-boundary-store + daemon-published-rudp-health'." -} - -if ($inspection.transportProfile.healthContract -ne "nightwing.cultnet-rudp-browser-reference-health") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference health contract is '$($inspection.transportProfile.healthContract)', expected 'nightwing.cultnet-rudp-browser-reference-health'." -} - -if ($inspection.transportProfile.idunnRudpHealth -ne "idunn.health-published-separately") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference transport profile Idunn health publication is '$($inspection.transportProfile.idunnRudpHealth)', expected 'idunn.health-published-separately'." -} - -if ($inspection.transportProfile.witnessPath -ne $RemoteCultCacheWitness) { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference transport profile witness path is '$($inspection.transportProfile.witnessPath)', expected '$RemoteCultCacheWitness'." -} - -if ($null -eq $inspection.daemonHealth) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its daemon-health record." -} - -if ($inspection.daemonHealth.daemonId -ne "nightwing-eve-browser-reference") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health record advertises daemon '$($inspection.daemonHealth.daemonId)', expected 'nightwing-eve-browser-reference'." -} - -if ($inspection.daemonHealth.state -ne "healthy") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health state is '$($inspection.daemonHealth.state)', expected 'healthy'." -} - -if ($inspection.daemonHealth.healthContract -ne "nightwing.cultnet-rudp-browser-reference-health") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health contract is '$($inspection.daemonHealth.healthContract)', expected 'nightwing.cultnet-rudp-browser-reference-health'." -} - -if ($inspection.daemonHealth.publicationSource -ne "daemon-published") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health publication source is '$($inspection.daemonHealth.publicationSource)', expected 'daemon-published'." -} - -if ($inspection.daemonHealth.transport -ne "cultnet.transport.rudp.v0") { - Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health transport is '$($inspection.daemonHealth.transport)', expected 'cultnet.transport.rudp.v0'." -} - -try { - $daemonHealthObservedAtUtc = [DateTimeOffset]::Parse($inspection.daemonHealth.observedAt) -} catch { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference daemon-health observedAt is malformed: '$($inspection.daemonHealth.observedAt)'." -} - -$daemonHealthAgeSeconds = ([DateTimeOffset]::UtcNow - $daemonHealthObservedAtUtc.ToUniversalTime()).TotalSeconds -if ($daemonHealthAgeSeconds -gt $MaxWitnessAgeSeconds) { - Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference daemon-health record is stale ($([math]::Round($daemonHealthAgeSeconds))s old)." -} - -Write-Host "Nightwing Eve browser reference is active, and its daemon-owned CultCache witness now certifies manifest, boundary, and daemon-health truth directly." diff --git a/scripts/health-nightwing-eve-dashboard.cmd b/scripts/health-nightwing-eve-dashboard.cmd deleted file mode 100644 index 4e5dc72..0000000 --- a/scripts/health-nightwing-eve-dashboard.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-nightwing-eve-dashboard.cmd archived: Nightwing Eve dashboard broker health is not daemon truth. Rebuild typed CultMesh/Odin dashboard state before restoring a health check. 1>&2 -exit /b 2 diff --git a/scripts/health-nightwing-eve-dashboard.ps1 b/scripts/health-nightwing-eve-dashboard.ps1 deleted file mode 100644 index 8dc25f0..0000000 --- a/scripts/health-nightwing-eve-dashboard.ps1 +++ /dev/null @@ -1 +0,0 @@ -throw "health-nightwing-eve-dashboard.ps1 archived: Nightwing Eve dashboard broker health is not daemon truth. Inspect typed CultMesh/Odin dashboard state after the publisher is rebuilt." diff --git a/scripts/health-odin.cmd b/scripts/health-odin.cmd deleted file mode 100644 index 128f0d0..0000000 --- a/scripts/health-odin.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-odin.cmd archived: Odin health is daemon-published over CultNet/RUDP as odin.cultnet-rudp-provider-health. Use health-odin.ps1 only as an explicit daemon witness inspection, not Idunn health truth. -exit /b 2 diff --git a/scripts/health-odin.ps1 b/scripts/health-odin.ps1 deleted file mode 100644 index 1d26b09..0000000 --- a/scripts/health-odin.ps1 +++ /dev/null @@ -1,68 +0,0 @@ -param( - [string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin"), - [int] $MaxSilenceSeconds = 120, - [string] $CultNetRudpBind = "0.0.0.0:17871" -) - -$ErrorActionPreference = "Stop" - -$pidPath = Join-Path $StateDir "odin.pid" -$outLog = Join-Path $StateDir "odin.out.log" -$errLog = Join-Path $StateDir "odin.err.log" -$cachePath = Join-Path $StateDir "odin.ccmp" - -if (-not (Test-Path -LiteralPath $pidPath)) { - throw "Odin PID file is missing at $pidPath" -} - -$pidText = (Get-Content -LiteralPath $pidPath -Raw).Trim() -if ($pidText -notmatch '^\d+$') { - throw "Odin PID file is invalid at ${pidPath}: '$pidText'" -} - -$process = Get-CimInstance Win32_Process -Filter "ProcessId = $pidText" -ErrorAction SilentlyContinue -if ($null -eq $process) { - throw "Odin process $pidText is not running" -} - -foreach ($pattern in @( - 'node.exe', - 'src\odin-coordinator.cjs', - "--stateDir $StateDir", - "--cultnet-rudp-bind $CultNetRudpBind", - '--idunn-daemon odin', - '--idunn-health-contract odin.cultnet-rudp-provider-health' -)) { - if ($process.CommandLine -notlike "*$pattern*") { - throw "Odin process command line is missing expected segment ${pattern}: $($process.CommandLine)" - } -} - -foreach ($logPath in @($outLog, $errLog)) { - if (-not (Test-Path -LiteralPath $logPath)) { - throw "Odin log is missing at $logPath" - } -} - -$newestLogWriteUtc = @($outLog, $errLog) | - ForEach-Object { (Get-Item -LiteralPath $_).LastWriteTimeUtc } | - Sort-Object -Descending | - Select-Object -First 1 -$logAgeSeconds = ([DateTime]::UtcNow - $newestLogWriteUtc).TotalSeconds -if ($logAgeSeconds -gt $MaxSilenceSeconds) { - throw "Odin logs have been silent for $([math]::Round($logAgeSeconds))s, exceeding $MaxSilenceSeconds seconds" -} - -if (-not (Test-Path -LiteralPath $cachePath)) { - throw "Odin CultMesh store is missing at $cachePath" -} - -$rudpPort = [int](($CultNetRudpBind -split ':')[-1]) -$udpListener = Get-NetUDPEndpoint -LocalPort $rudpPort -ErrorAction SilentlyContinue | - Where-Object { $_.OwningProcess -eq [int]$pidText } | - Select-Object -First 1 -if ($null -eq $udpListener) { - throw "Odin is not listening on UDP port $rudpPort as process $pidText" -} - -Write-Host "Odin healthy: pid=$pidText cultmesh_rudp_udp=$rudpPort store=$cachePath log_age=$([math]::Round($logAgeSeconds))s" diff --git a/scripts/health-raven-capture-mux.cmd b/scripts/health-raven-capture-mux.cmd deleted file mode 100644 index 334d7cb..0000000 --- a/scripts/health-raven-capture-mux.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-raven-capture-mux.cmd archived: Raven capture mux no longer owns Muninn daemon health; Muninn publishes muninn.cultnet-rudp-remote-telemetry-health over CultNet/RUDP. -exit /b 2 diff --git a/scripts/health-raven-capture-mux.ps1 b/scripts/health-raven-capture-mux.ps1 deleted file mode 100644 index 779f902..0000000 --- a/scripts/health-raven-capture-mux.ps1 +++ /dev/null @@ -1,2 +0,0 @@ -Write-Error "health-raven-capture-mux.ps1 archived: Raven capture mux no longer owns Muninn daemon health; Muninn publishes muninn.cultnet-rudp-remote-telemetry-health over CultNet/RUDP." -exit 2 diff --git a/scripts/health-stonks.cmd b/scripts/health-stonks.cmd deleted file mode 100644 index 9994234..0000000 --- a/scripts/health-stonks.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-stonks.cmd archived: Stonks health is daemon-published over CultNet/RUDP as stonks.cultnet-rudp-market-health. Do not probe localhost HTTP for daemon health. 1>&2 -exit /b 2 diff --git a/scripts/health-vili.cmd b/scripts/health-vili.cmd deleted file mode 100644 index 4a2e70d..0000000 --- a/scripts/health-vili.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-vili.cmd archived: Vili health is daemon-published over CultNet/RUDP as vili.cultnet-rudp-animation-health. Do not probe HTTP for daemon health. 1>&2 -exit /b 2 diff --git a/scripts/health-voidbot.cmd b/scripts/health-voidbot.cmd deleted file mode 100644 index 6c600cf..0000000 --- a/scripts/health-voidbot.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-voidbot.cmd archived: VoidBot health is daemon-published over CultNet/RUDP as voidbot.cultnet-rudp-stack-health. Use health-voidbot.ps1 only as an explicit witness inspection, not Idunn health truth. 1>&2 -exit /b 2 diff --git a/scripts/health-voidbot.ps1 b/scripts/health-voidbot.ps1 deleted file mode 100644 index cc3196a..0000000 --- a/scripts/health-voidbot.ps1 +++ /dev/null @@ -1,181 +0,0 @@ -param( - [string] $VoidBotRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "VoidBot"), - [int] $MaxOrchestratorAgeSeconds = 180, - [int] $MaxCultMeshStoreAgeSeconds = 180 -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -function Fail-IdunnHealth { - param( - [string] $State, - [string] $Message - ) - - Write-Output "idunn.health.state=$State" - throw $Message -} - -function Read-JsonFile { - param([Parameter(Mandatory = $true)][string] $Path) - - if (-not (Test-Path -LiteralPath $Path)) { - return $null - } - - $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 - if ([string]::IsNullOrWhiteSpace($raw)) { - return $null - } - - return $raw | ConvertFrom-Json -} - -function Get-JsonPropertyValue { - param( - [Parameter(Mandatory = $false)] $Object, - [Parameter(Mandatory = $true)][string] $Name, - [Parameter(Mandatory = $false)] $Default = $null - ) - - if ($null -eq $Object) { - return $Default - } - - $property = $Object.PSObject.Properties[$Name] - if ($null -eq $property) { - return $Default - } - - return $property.Value -} - -function Get-ProcessOrFail { - param( - [Parameter(Mandatory = $true)][string] $Role, - [Parameter(Mandatory = $true)][int] $ProcessId - ) - - $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue - if ($null -eq $process) { - Fail-IdunnHealth -State "failed" -Message "VoidBot $Role process PID $ProcessId is not running." - } - - return $process -} - -function Assert-StoreContains { - param( - [Parameter(Mandatory = $true)][string] $StorePath, - [Parameter(Mandatory = $true)][string[]] $Tokens - ) - - $rg = (Get-Command rg.exe -ErrorAction SilentlyContinue).Source - if ([string]::IsNullOrWhiteSpace($rg)) { - Fail-IdunnHealth -State "degraded" -Message "ripgrep is required to inspect the VoidBot CultMesh witness store." - } - - foreach ($token in $Tokens) { - & $rg -a -q --fixed-strings -- $token $StorePath - if ($LASTEXITCODE -ne 0) { - Fail-IdunnHealth -State "degraded" -Message "VoidBot CultMesh witness store is missing '$token'." - } - } -} - -$runtimePath = Join-Path $VoidBotRoot ".voidbot\status\runtime-stack.json" -$orchestratorPath = Join-Path $VoidBotRoot ".voidbot\status\gamecult-orchestrator.json" -$cultMeshStorePath = Join-Path $VoidBotRoot ".voidbot\status\cultmesh\voidbot-swarm-state.cc" - -$runtime = Read-JsonFile -Path $runtimePath -if ($null -eq $runtime) { - Fail-IdunnHealth -State "failed" -Message "VoidBot runtime state is missing at $runtimePath." -} - -if (-not [bool](Get-JsonPropertyValue -Object $runtime -Name "ready" -Default $false)) { - Fail-IdunnHealth -State "failed" -Message "VoidBot runtime stack reports ready=false." -} - -$runtimeStage = [string](Get-JsonPropertyValue -Object $runtime -Name "stage" -Default "unknown") -if ($runtimeStage -ne "ready") { - Fail-IdunnHealth -State "failed" -Message "VoidBot runtime stack stage is '$runtimeStage', expected 'ready'." -} - -$bot = Get-JsonPropertyValue -Object $runtime -Name "bot" -$worker = Get-JsonPropertyValue -Object $runtime -Name "worker" -$botPid = if ($null -ne $bot) { [int](Get-JsonPropertyValue -Object $bot -Name "pid" -Default 0) } else { 0 } -$workerPid = if ($null -ne $worker) { [int](Get-JsonPropertyValue -Object $worker -Name "pid" -Default 0) } else { 0 } -if ($botPid -le 0) { - Fail-IdunnHealth -State "failed" -Message "VoidBot runtime state does not contain a live bot PID." -} -if ($workerPid -le 0) { - Fail-IdunnHealth -State "failed" -Message "VoidBot runtime state does not contain a live worker PID." -} - -$botProcess = Get-ProcessOrFail -Role "bot" -ProcessId $botPid -$workerProcess = Get-ProcessOrFail -Role "worker" -ProcessId $workerPid - -$orchestrator = Read-JsonFile -Path $orchestratorPath -if ($null -eq $orchestrator -or $null -eq $orchestrator.organs) { - Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator state is missing at $orchestratorPath." -} - -$surfaceState = $orchestrator.organs.PSObject.Properties["voidbot-swarm-surface"] -if ($null -eq $surfaceState) { - Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator does not report the voidbot-swarm-surface organ." -} -$surfaceState = $surfaceState.Value - -if ($surfaceState.lastStatus -ne "ok") { - Fail-IdunnHealth -State "degraded" -Message "VoidBot swarm surface organ lastStatus is '$($surfaceState.lastStatus)', expected 'ok'." -} - -if ([string]::IsNullOrWhiteSpace($surfaceState.lastFinishedAt)) { - Fail-IdunnHealth -State "failed" -Message "VoidBot swarm surface organ has no lastFinishedAt timestamp." -} - -$surfaceFinishedAt = [DateTimeOffset]::Parse($surfaceState.lastFinishedAt).ToUniversalTime() -$surfaceAgeSeconds = ([DateTimeOffset]::UtcNow - $surfaceFinishedAt).TotalSeconds -if ($surfaceAgeSeconds -gt $MaxOrchestratorAgeSeconds) { - Fail-IdunnHealth -State "failed" -Message "VoidBot swarm surface organ is stale ($([math]::Round($surfaceAgeSeconds))s old)." -} - -$orchestratorFile = Get-Item -LiteralPath $orchestratorPath -ErrorAction SilentlyContinue -if ($null -eq $orchestratorFile) { - Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator state file is missing at $orchestratorPath." -} -$orchestratorAgeSeconds = ([DateTimeOffset]::UtcNow - [DateTimeOffset]$orchestratorFile.LastWriteTimeUtc).TotalSeconds -if ($orchestratorAgeSeconds -gt $MaxOrchestratorAgeSeconds) { - Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator state file is stale ($([math]::Round($orchestratorAgeSeconds))s old)." -} - -$cultMeshStore = Get-Item -LiteralPath $cultMeshStorePath -ErrorAction SilentlyContinue -if ($null -eq $cultMeshStore) { - Fail-IdunnHealth -State "failed" -Message "VoidBot CultMesh witness store is missing at $cultMeshStorePath." -} -if ($cultMeshStore.Length -le 0) { - Fail-IdunnHealth -State "failed" -Message "VoidBot CultMesh witness store at $cultMeshStorePath is empty." -} - -$cultMeshStoreAgeSeconds = ([DateTimeOffset]::UtcNow - [DateTimeOffset]$cultMeshStore.LastWriteTimeUtc).TotalSeconds -if ($cultMeshStoreAgeSeconds -gt $MaxCultMeshStoreAgeSeconds) { - Fail-IdunnHealth -State "failed" -Message "VoidBot CultMesh witness store is stale ($([math]::Round($cultMeshStoreAgeSeconds))s old)." -} - -Assert-StoreContains -StorePath $cultMeshStorePath -Tokens @( - "voidbot.provider_advertisement_catalog.v0", - "idunn.command_boundary.v1", - "idunn.daemon_transport_profile.v1", - "voidbot.providers", - "voidbot.discord", - "voidbot.reddit", - "voidbot.archive", - "voidbot.source", - "voidbot.repo_face", - "voidbot.swarm", - "command-boundary:voidbot", - "transport:voidbot" -) - -Write-Host "VoidBot runtime is ready, bot PID $($botProcess.Id) and worker PID $($workerProcess.Id) are live, and the daemon-owned CultMesh witness store is fresh." diff --git a/scripts/health-weksa.cmd b/scripts/health-weksa.cmd deleted file mode 100644 index 0ff58c7..0000000 --- a/scripts/health-weksa.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-weksa.cmd archived: Weksa health is daemon-published over CultNet/RUDP as weksa.cultnet-rudp-provider-health. Do not run local command health for daemon truth. 1>&2 -exit /b 2 diff --git a/scripts/health-yggdrasil-heimdall.cmd b/scripts/health-yggdrasil-heimdall.cmd deleted file mode 100644 index e422cca..0000000 --- a/scripts/health-yggdrasil-heimdall.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-yggdrasil-heimdall.cmd archived: Heimdall health is daemon-published over CultNet/RUDP as heimdall.cultnet-rudp-provider-health. Use health-yggdrasil-source-app.ps1 only as an explicit deployment witness, not Idunn health truth. -exit /b 2 diff --git a/scripts/health-yggdrasil-repixelizer.cmd b/scripts/health-yggdrasil-repixelizer.cmd deleted file mode 100644 index 9aae84c..0000000 --- a/scripts/health-yggdrasil-repixelizer.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-yggdrasil-repixelizer.cmd archived: Repixelizer health is daemon-published over CultNet/RUDP as repixelizer.cultnet-rudp-service-health. Use health-yggdrasil-source-app.ps1 only as an explicit deployment witness, not Idunn health truth. -exit /b 2 diff --git a/scripts/health-yggdrasil-source-app.ps1 b/scripts/health-yggdrasil-source-app.ps1 deleted file mode 100644 index 54df0c2..0000000 --- a/scripts/health-yggdrasil-source-app.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -param( - [Parameter(Mandatory = $true)] [string] $AppId, - [Parameter(Mandatory = $true)] [string] $RepoRoot, - [Parameter(Mandatory = $true)] [string] $RemoteAppHome, - [Parameter(Mandatory = $true)] [string] $CheckScript, - [string] $SshTarget = "yggdrasil" -) - -$ErrorActionPreference = "Stop" - -$localCommit = (git -C $RepoRoot rev-parse HEAD).Trim() -if ([string]::IsNullOrWhiteSpace($localCommit)) { - throw "Could not determine git revision for $RepoRoot" -} - -$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))" -$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10") -$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10", $SshTarget) -scp.exe @scpArgs $CheckScript "${SshTarget}:$remoteCheck" | Out-Null -if ($LASTEXITCODE -ne 0) { throw "scp check script failed for $AppId" } - -$manifest = ssh.exe @sshArgs "cat '$RemoteAppHome/deployment-manifest.txt' 2>/dev/null" -if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($manifest)) { - throw "$AppId deploy manifest is missing on Yggdrasil." -} - -$remoteCommit = "" -foreach ($line in $manifest) { - if ($line -like "gitCommit=*") { - $remoteCommit = $line.Substring("gitCommit=".Length).Trim() - } -} - -if ($remoteCommit -ne $localCommit) { - throw "$AppId deploy is stale. remote=$remoteCommit local=$localCommit" -} - -ssh.exe @sshArgs "chmod +x '$remoteCheck' && sudo -n bash '$remoteCheck'" -if ($LASTEXITCODE -ne 0) { - throw "$AppId remote health check failed." -} - -Write-Host "$AppId is active and deployed at $localCommit." diff --git a/scripts/health-yggdrasil-streampixels.cmd b/scripts/health-yggdrasil-streampixels.cmd deleted file mode 100644 index c54e85e..0000000 --- a/scripts/health-yggdrasil-streampixels.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-yggdrasil-streampixels.cmd archived: StreamPixels health is daemon-published over CultNet/RUDP as streampixels.cultnet-rudp-service-health. Use health-yggdrasil-source-app.ps1 only as an explicit deployment witness, not Idunn health truth. -exit /b 2 diff --git a/scripts/linux/deploy-yggdrasil-heimdall b/scripts/linux/deploy-yggdrasil-heimdall deleted file mode 100755 index 6904e0b..0000000 --- a/scripts/linux/deploy-yggdrasil-heimdall +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$(id -u)" -ne 0 ]; then - echo "deploy-yggdrasil-heimdall must run as root through Idunn" >&2 - exit 1 -fi - -source_tar=/srv/heimdall/heimdall-source.tar -cultlib_tar=/srv/heimdall/heimdall-cultlib.tar -releases=/srv/heimdall/app/releases -current=/srv/heimdall/app/current -shared_cultlib="$releases/CultLib" -compose=/srv/compose/yggdrasil-apps.yaml - -for required in "$source_tar" "$cultlib_tar" "$compose"; do - if [ ! -f "$required" ]; then - echo "missing audited Heimdall deployment input: $required" >&2 - exit 1 - fi -done - -artifact_sha=$(sha256sum "$source_tar" | awk '{print $1}') -release="$releases/sha-${artifact_sha}" -previous=$(readlink -f "$current" 2>/dev/null || true) - -if [ ! -f "$release/.idunn-build-complete" ]; then - staging="$release.staging.$$" - rm -rf "$staging" - install -d -o heimdall -g heimdall "$staging" "$shared_cultlib" - tar -xf "$source_tar" -C "$staging" - rm -rf "$shared_cultlib/packages/cultcache-ts" "$shared_cultlib/packages/cultnet-ts" - tar -xf "$cultlib_tar" -C "$shared_cultlib" - - docker run --rm \ - -v /srv/heimdall:/srv/heimdall \ - -w "$staging" \ - node:24.14.1-bookworm-slim@sha256:b506e7321f176aae77317f99d67a24b272c1f09f1d10f1761f2773447d8da26c \ - sh -c 'corepack enable && pnpm install --frozen-lockfile && pnpm typecheck && pnpm test && pnpm build && pnpm prune --prod' - - printf '%s\n' "$artifact_sha" > "$staging/.idunn-build-complete" - chown -R heimdall:heimdall "$staging" "$shared_cultlib" - mv "$staging" "$release" -fi - -ln -sfn "$release" "$current" -chown -h heimdall:heimdall "$current" - -if ! docker compose -f "$compose" up -d --force-recreate heimdall; then - if [ -n "$previous" ] && [ -d "$previous" ]; then - ln -sfn "$previous" "$current" - chown -h heimdall:heimdall "$current" - docker compose -f "$compose" up -d --force-recreate heimdall || true - fi - exit 1 -fi - -healthy=0 -for _ in $(seq 1 30); do - if curl -fsS http://127.0.0.1:4100/healthz >/dev/null; then - healthy=1 - break - fi - sleep 2 -done - -if [ "$healthy" -ne 1 ]; then - echo "Heimdall failed health verification after deploying $artifact_sha" >&2 - if [ -n "$previous" ] && [ -d "$previous" ]; then - ln -sfn "$previous" "$current" - chown -h heimdall:heimdall "$current" - docker compose -f "$compose" up -d --force-recreate heimdall || true - fi - exit 1 -fi - -echo "Heimdall deployed audited source artifact $artifact_sha" diff --git a/scripts/restart-nightwing-eve-browser-reference.cmd b/scripts/restart-nightwing-eve-browser-reference.cmd deleted file mode 100644 index fcf2b2b..0000000 --- a/scripts/restart-nightwing-eve-browser-reference.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-nightwing-eve-browser-reference.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-nightwing-eve-browser-reference.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-nightwing-eve-browser-reference.ps1 diff --git a/scripts/restart-nightwing-eve-browser-reference.ps1 b/scripts/restart-nightwing-eve-browser-reference.ps1 deleted file mode 100644 index 63d2f3a..0000000 --- a/scripts/restart-nightwing-eve-browser-reference.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -param( - [string] $SshTarget = "nwroot", - [string] $Service = "nightwing-eve-browser-reference.service" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "restart-nightwing-eve-browser-reference.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." -} - -ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $SshTarget "systemctl restart $Service && systemctl is-active --quiet $Service" -if ($LASTEXITCODE -ne 0) { - throw "Remote restart failed for $Service on $SshTarget with exit code $LASTEXITCODE" -} - -Write-Host "Restarted $Service on $SshTarget." diff --git a/scripts/restart-nightwing-eve-dashboard.cmd b/scripts/restart-nightwing-eve-dashboard.cmd deleted file mode 100644 index 18cd822..0000000 --- a/scripts/restart-nightwing-eve-dashboard.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo restart-nightwing-eve-dashboard.cmd archived: Mimir.EveDashboard's HTTP/WebSocket broker is cut. Rebuild a typed CultMesh/Odin dashboard publisher before restoring Idunn lifecycle authority. 1>&2 -exit /b 2 diff --git a/scripts/restart-nightwing-eve-dashboard.ps1 b/scripts/restart-nightwing-eve-dashboard.ps1 deleted file mode 100644 index 996f7ab..0000000 --- a/scripts/restart-nightwing-eve-dashboard.ps1 +++ /dev/null @@ -1 +0,0 @@ -throw "restart-nightwing-eve-dashboard.ps1 archived: Mimir.EveDashboard's HTTP/WebSocket broker is cut. Rebuild a typed CultMesh/Odin dashboard publisher before restoring Idunn lifecycle authority." diff --git a/scripts/restart-odin.cmd b/scripts/restart-odin.cmd deleted file mode 100644 index 3b5534c..0000000 --- a/scripts/restart-odin.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-odin.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-odin.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-odin.ps1 diff --git a/scripts/restart-odin.ps1 b/scripts/restart-odin.ps1 deleted file mode 100644 index 334977b..0000000 --- a/scripts/restart-odin.ps1 +++ /dev/null @@ -1,46 +0,0 @@ -param( - [string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin"), - [string] $CultNetRudpBind = "0.0.0.0:17871", - [string] $IdunnRudpHealth = $(if ($env:ODIN_IDUNN_RUDP_HEALTH) { $env:ODIN_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }), - [string] $IdunnDaemon = "odin", - [string] $IdunnHealthContract = "odin.cultnet-rudp-provider-health" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "restart-odin.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." -} - -$repoRoot = Split-Path -Parent $PSScriptRoot -$stopScript = Join-Path $PSScriptRoot "stop-odin.ps1" -$startScript = Join-Path $PSScriptRoot "start-odin.ps1" -$powerShellExe = Join-Path $env:WINDIR "System32\WindowsPowerShell\v1.0\powershell.exe" - -if (-not (Test-Path -LiteralPath $powerShellExe)) { - throw "PowerShell executable is missing at $powerShellExe" -} - -$startArgs = @( - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", $startScript, - "-StateDir", $StateDir, - "-CultNetRudpBind", $CultNetRudpBind, - "-IdunnDaemon", $IdunnDaemon, - "-IdunnHealthContract", $IdunnHealthContract -) - -if (-not [string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - $startArgs += @("-IdunnRudpHealth", $IdunnRudpHealth) -} - -& $powerShellExe -NoProfile -ExecutionPolicy Bypass -File $stopScript -StateDir $StateDir -if ($LASTEXITCODE -ne 0) { - throw "stop-odin.ps1 failed with exit code $LASTEXITCODE" -} - -& $powerShellExe @startArgs -if ($LASTEXITCODE -ne 0) { - throw "start-odin.ps1 failed with exit code $LASTEXITCODE" -} diff --git a/scripts/restart-stonks.cmd b/scripts/restart-stonks.cmd deleted file mode 100644 index fd9ff6c..0000000 --- a/scripts/restart-stonks.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-stonks.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-stonks.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-stonks.ps1 diff --git a/scripts/restart-stonks.ps1 b/scripts/restart-stonks.ps1 deleted file mode 100644 index 3366720..0000000 --- a/scripts/restart-stonks.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -$ErrorActionPreference = "Stop" - -$stonksRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Stonks") -$stateDir = Join-Path $stonksRoot "scratch\stonks" -$pidPath = Join-Path $stateDir "stonks.pid" -$startScript = Join-Path $stonksRoot "scripts\start-stonks.ps1" - -if (Test-Path -LiteralPath $pidPath) { - $pidText = (Get-Content -LiteralPath $pidPath -Raw).Trim() - if ($pidText -match "^\d+$") { - $process = Get-Process -Id ([int] $pidText) -ErrorAction SilentlyContinue - if ($null -ne $process) { - Stop-Process -Id $process.Id -Force - Start-Sleep -Milliseconds 500 - } - } - Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue -} - -& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript -exit $LASTEXITCODE diff --git a/scripts/restart-vili.cmd b/scripts/restart-vili.cmd deleted file mode 100644 index 58bbd49..0000000 --- a/scripts/restart-vili.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-vili.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-vili.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0restart-vili.ps1" %* diff --git a/scripts/restart-vili.ps1 b/scripts/restart-vili.ps1 deleted file mode 100644 index 92a8715..0000000 --- a/scripts/restart-vili.ps1 +++ /dev/null @@ -1,287 +0,0 @@ -param( - [string] $RavenHost = "raven", - [string] $LocalViliRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Vili"), - [string] $LocalCultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"), - [string] $RemoteViliRoot = "E:\Projects\Vili", - [int] $Port = 8824, - [string] $HostName = "0.0.0.0", - [string] $StateRoot = "E:\Projects\Vili\.vili", - [string] $IdunnRudpHealth = $(if ($env:VILI_IDUNN_RUDP_HEALTH) { $env:VILI_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }), - [string] $IdunnDaemon = "vili", - [string] $IdunnHealthContract = "vili.cultnet-rudp-animation-health", - [int] $IdunnHealthIntervalSeconds = 15 -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "restart-vili.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." -} - -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by VILI_IDUNN_RUDP_HEALTH, IDUNN_RUDP_HEALTH, or -IdunnRudpHealth; no WireGuard endpoint default is allowed." -} - -$localFiles = @( - (Join-Path $LocalViliRoot "package.json"), - (Join-Path $LocalViliRoot "package-lock.json"), - (Join-Path $LocalViliRoot "scripts\idunn-rudp.cjs"), - (Join-Path $LocalViliRoot "scripts\install-vili-task.ps1"), - (Join-Path $LocalViliRoot "scripts\restart-vili-daemon.cmd"), - (Join-Path $LocalViliRoot "scripts\restart-vili-daemon.ps1"), - (Join-Path $LocalViliRoot "scripts\run-hidden-powershell.vbs"), - (Join-Path $LocalViliRoot "scripts\run-vili-daemon.ps1"), - (Join-Path $LocalViliRoot "scripts\start-vili-daemon.ps1"), - (Join-Path $LocalViliRoot "scripts\vili-daemon.mjs"), - (Join-Path $LocalCultLibRoot "packages\cultnet-ts"), - (Join-Path $LocalCultLibRoot "packages\cultcache-ts"), - (Join-Path $LocalCultLibRoot "node_modules\ajv"), - (Join-Path $LocalCultLibRoot "node_modules\fast-deep-equal"), - (Join-Path $LocalCultLibRoot "node_modules\fast-uri"), - (Join-Path $LocalCultLibRoot "node_modules\json-schema-traverse"), - (Join-Path $LocalCultLibRoot "node_modules\require-from-string"), - (Join-Path $LocalViliRoot "node_modules\@msgpack") -) - -foreach ($path in $localFiles) { - if (-not (Test-Path -LiteralPath $path)) { - throw "Required Vili runtime path not found: $path" - } -} - -$syncId = [guid]::NewGuid().ToString("N") -$remoteSyncRootPs = "$RemoteViliRoot\.odin-vili-sync-$syncId" -$remoteSyncRootSftp = ($remoteSyncRootPs -replace "\\", "/") -$remoteTempPs = "C:\Windows\Temp\odin-raven-vili-restart-$syncId.ps1" -$remoteTempSftp = ($remoteTempPs -replace "\\", "/") -$remoteBundlePs = "C:\Windows\Temp\odin-raven-vili-bundle-$syncId.zip" -$remoteBundleSftp = ($remoteBundlePs -replace "\\", "/") -$localRemoteScript = Join-Path $env:TEMP "odin-raven-vili-restart-$syncId.ps1" -$localSftpBatch = Join-Path $env:TEMP "odin-raven-vili-restart-$syncId.sftp" -$localBundleRoot = Join-Path $env:TEMP "odin-raven-vili-bundle-$syncId" -$localBundleScripts = Join-Path $localBundleRoot "scripts" -$localBundleNodeModules = Join-Path $localBundleRoot "node_modules" -$localBundleZip = Join-Path $env:TEMP "odin-raven-vili-bundle-$syncId.zip" - -$remoteScript = @" -`$ErrorActionPreference = "Stop" -`$ProgressPreference = "SilentlyContinue" - -`$remoteRoot = "$RemoteViliRoot" -`$syncRoot = "$remoteSyncRootPs" -`$scriptsRoot = Join-Path `$remoteRoot "scripts" -`$stateRoot = "$StateRoot" -`$nodeModulesRoot = Join-Path `$remoteRoot "node_modules" -`$pidPath = Join-Path `$stateRoot "vili-daemon.pid" - -try { - Expand-Archive -LiteralPath "$remoteBundlePs" -DestinationPath `$syncRoot -Force - if (-not (Test-Path -LiteralPath `$syncRoot)) { - throw "Uploaded sync root not found at `$syncRoot" - } - - if (Test-Path -LiteralPath `$pidPath) { - `$existingPid = (Get-Content -LiteralPath `$pidPath -Raw -ErrorAction SilentlyContinue).Trim() - if (`$existingPid -match '^\d+$') { - `$process = Get-Process -Id ([int] `$existingPid) -ErrorAction SilentlyContinue - if (`$null -ne `$process) { - Stop-Process -Id `$process.Id -Force - Start-Sleep -Seconds 1 - } - } - Remove-Item -LiteralPath `$pidPath -Force -ErrorAction SilentlyContinue - } - - New-Item -ItemType Directory -Force -Path `$scriptsRoot | Out-Null - New-Item -ItemType Directory -Force -Path `$stateRoot | Out-Null - - Copy-Item -LiteralPath (Join-Path `$syncRoot "package.json") -Destination (Join-Path `$remoteRoot "package.json") -Force - Copy-Item -LiteralPath (Join-Path `$syncRoot "package-lock.json") -Destination (Join-Path `$remoteRoot "package-lock.json") -Force - - `$scriptFiles = @( - "idunn-rudp.cjs", - "install-vili-task.ps1", - "restart-vili-daemon.cmd", - "restart-vili-daemon.ps1", - "run-hidden-powershell.vbs", - "run-vili-daemon.ps1", - "start-vili-daemon.ps1", - "vili-daemon.mjs" - ) - - foreach (`$name in `$scriptFiles) { - Copy-Item -LiteralPath (Join-Path `$syncRoot "scripts\`$name") -Destination (Join-Path `$scriptsRoot `$name) -Force - } - - if (Test-Path -LiteralPath `$nodeModulesRoot) { - Remove-Item -LiteralPath `$nodeModulesRoot -Recurse -Force - } - Move-Item -LiteralPath (Join-Path `$syncRoot "node_modules") -Destination `$nodeModulesRoot -Force - - & powershell.exe @( - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", "Bypass", - "-File", (Join-Path `$scriptsRoot "install-vili-task.ps1"), - "-TaskName", "GameCult\Vili", - "-Port", "$Port", - "-HostName", "$HostName", - "-StateRoot", "$StateRoot", - "-IdunnRudpHealth", "$IdunnRudpHealth", - "-IdunnDaemon", "$IdunnDaemon", - "-IdunnHealthContract", "$IdunnHealthContract", - "-IdunnHealthIntervalSeconds", "$IdunnHealthIntervalSeconds" - ) - - & powershell.exe @( - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", "Bypass", - "-File", (Join-Path `$scriptsRoot "restart-vili-daemon.ps1"), - "-Port", "$Port", - "-HostName", "$HostName", - "-StateRoot", "$StateRoot", - "-IdunnRudpHealth", "$IdunnRudpHealth", - "-IdunnDaemon", "$IdunnDaemon", - "-IdunnHealthContract", "$IdunnHealthContract", - "-IdunnHealthIntervalSeconds", "$IdunnHealthIntervalSeconds" - ) - - `$task = Get-ScheduledTask -TaskName "Vili" -TaskPath "\GameCult\" -ErrorAction Stop - `$action = @(`$task.Actions)[0] - if (`$action.Execute -notmatch '(^|\\)wscript\.exe$') { - throw "GameCult\Vili action executes `$(`$action.Execute), expected wscript.exe" - } - if (`$action.Arguments -notlike "*run-hidden-powershell.vbs*") { - throw "GameCult\Vili action arguments do not reference run-hidden-powershell.vbs" - } - `$taskHealthChecks = @( - (`$action.Arguments -like "*-IdunnRudpHealth*"), - (`$action.Arguments -like "*$IdunnRudpHealth*"), - (`$action.Arguments -like "*-IdunnDaemon*"), - (`$action.Arguments -like "*$IdunnDaemon*"), - (`$action.Arguments -like "*-IdunnHealthContract*"), - (`$action.Arguments -like "*$IdunnHealthContract*") - ) - if (`$taskHealthChecks -contains `$false) { - throw "GameCult\Vili action arguments do not carry the Idunn RUDP health contract." - } - - `$process = Get-CimInstance Win32_Process | - Where-Object { `$_.CommandLine -like '*vili-daemon.mjs*' } | - Select-Object -First 1 - if (`$null -eq `$process) { - throw "Vili process is not running after restart." - } - `$processHealthChecks = @( - (`$process.CommandLine -like "*--idunn-rudp-health*"), - (`$process.CommandLine -like "*$IdunnRudpHealth*"), - (`$process.CommandLine -like "*--idunn-daemon*"), - (`$process.CommandLine -like "*$IdunnDaemon*"), - (`$process.CommandLine -like "*--idunn-health-contract*"), - (`$process.CommandLine -like "*$IdunnHealthContract*") - ) - if (`$processHealthChecks -contains `$false) { - throw "Vili process command line does not include the Idunn RUDP health arguments." - } - - `$operatorStatePath = Join-Path `$stateRoot "operator-state.json" - `$deadline = (Get-Date).AddSeconds(30) - `$state = `$null - while ((Get-Date) -lt `$deadline) { - if (Test-Path -LiteralPath `$operatorStatePath) { - try { - `$state = Get-Content -LiteralPath `$operatorStatePath -Raw | ConvertFrom-Json - if (`$null -ne `$state.idunnRudpHealth -and `$state.idunnRudpHealth.status -eq "published") { - break - } - } catch { - } - } - Start-Sleep -Seconds 1 - } - if (`$null -eq `$state -or `$null -eq `$state.idunnRudpHealth) { - throw "Vili daemon state file did not report idunnRudpHealth after restart." - } - if (`$state.idunnRudpHealth.status -ne "published") { - throw "Vili idunnRudpHealth status is `$(`$state.idunnRudpHealth.status): `$(`$state.idunnRudpHealth.error)" - } - - Write-Host "Vili Raven runtime repaired and restarted with Idunn RUDP health." - Write-Host "ProcessId=`$(`$process.ProcessId)" - Write-Host "TaskArguments=`$(`$action.Arguments)" - Write-Host "HealthStatus=`$(`$state.idunnRudpHealth.status)" - Write-Host "HealthContract=`$(`$state.idunnRudpHealth.contract)" -} finally { - Remove-Item -LiteralPath `$syncRoot -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath "$remoteBundlePs" -Force -ErrorAction SilentlyContinue -} -"@ - -$batchLines = @( - "put ""$localBundleZip"" ""$remoteBundleSftp""", - "put ""$localRemoteScript"" ""$remoteTempSftp""" -) - -try { - $moduleCopies = @( - @{ Source = (Join-Path $LocalCultLibRoot "packages\cultnet-ts"); Destination = (Join-Path $localBundleNodeModules "cultnet-ts") }, - @{ Source = (Join-Path $LocalCultLibRoot "packages\cultcache-ts"); Destination = (Join-Path $localBundleNodeModules "cultcache-ts") }, - @{ Source = (Join-Path $LocalCultLibRoot "node_modules\ajv"); Destination = (Join-Path $localBundleNodeModules "ajv") }, - @{ Source = (Join-Path $LocalCultLibRoot "node_modules\fast-deep-equal"); Destination = (Join-Path $localBundleNodeModules "fast-deep-equal") }, - @{ Source = (Join-Path $LocalCultLibRoot "node_modules\fast-uri"); Destination = (Join-Path $localBundleNodeModules "fast-uri") }, - @{ Source = (Join-Path $LocalCultLibRoot "node_modules\json-schema-traverse"); Destination = (Join-Path $localBundleNodeModules "json-schema-traverse") }, - @{ Source = (Join-Path $LocalCultLibRoot "node_modules\require-from-string"); Destination = (Join-Path $localBundleNodeModules "require-from-string") }, - @{ Source = (Join-Path $LocalViliRoot "node_modules\@msgpack"); Destination = (Join-Path $localBundleNodeModules "@msgpack") } - ) - New-Item -ItemType Directory -Force -Path $localBundleScripts, $localBundleNodeModules | Out-Null - Copy-Item -LiteralPath (Join-Path $LocalViliRoot "package.json") -Destination (Join-Path $localBundleRoot "package.json") -Force - Copy-Item -LiteralPath (Join-Path $LocalViliRoot "package-lock.json") -Destination (Join-Path $localBundleRoot "package-lock.json") -Force - $scriptCopies = @( - "idunn-rudp.cjs", - "install-vili-task.ps1", - "restart-vili-daemon.cmd", - "restart-vili-daemon.ps1", - "run-hidden-powershell.vbs", - "run-vili-daemon.ps1", - "start-vili-daemon.ps1", - "vili-daemon.mjs" - ) - foreach ($scriptName in $scriptCopies) { - Copy-Item -LiteralPath (Join-Path $LocalViliRoot "scripts\$scriptName") -Destination (Join-Path $localBundleScripts $scriptName) -Force - } - foreach ($copy in $moduleCopies) { - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $copy.Destination) | Out-Null - Copy-Item -LiteralPath $copy.Source -Destination $copy.Destination -Recurse -Force - } - if (Test-Path -LiteralPath $localBundleZip) { - Remove-Item -LiteralPath $localBundleZip -Force - } - Compress-Archive -Path (Join-Path $localBundleRoot "*") -DestinationPath $localBundleZip -CompressionLevel Optimal - Set-Content -LiteralPath $localRemoteScript -Encoding ASCII -Value $remoteScript - Set-Content -LiteralPath $localSftpBatch -Encoding ASCII -Value ($batchLines -join "`r`n") - & sftp.exe -b $localSftpBatch $RavenHost - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - $remoteRunner = @" -`$ErrorActionPreference = "Stop" -try { - & "$remoteTempPs" - `$code = `$LASTEXITCODE -} finally { - Remove-Item -LiteralPath "$remoteTempPs" -Force -ErrorAction SilentlyContinue -} -exit `$code -"@ - $encodedRunner = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteRunner)) - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedRunner" -} finally { - Remove-Item -LiteralPath $localRemoteScript, $localSftpBatch -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $localBundleZip -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $localBundleRoot -Recurse -Force -ErrorAction SilentlyContinue -} - -$restartExit = $LASTEXITCODE -exit $restartExit diff --git a/scripts/restart-weksa.cmd b/scripts/restart-weksa.cmd deleted file mode 100644 index 8ddbf57..0000000 --- a/scripts/restart-weksa.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-weksa.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-weksa.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -call %~dp0..\..\weksa\scripts\restart-weksa-daemon.cmd diff --git a/scripts/start-odin.ps1 b/scripts/start-odin.ps1 deleted file mode 100644 index 47e0751..0000000 --- a/scripts/start-odin.ps1 +++ /dev/null @@ -1,68 +0,0 @@ -param( - [string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin"), - [string] $CultNetRudpBind = "0.0.0.0:17871", - [string] $IdunnRudpHealth = $(if ($env:ODIN_IDUNN_RUDP_HEALTH) { $env:ODIN_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }), - [string] $IdunnDaemon = "odin", - [string] $IdunnHealthContract = "odin.cultnet-rudp-provider-health", - [switch] $Foreground -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Split-Path -Parent $PSScriptRoot -$scriptPath = Join-Path $repoRoot "src\odin-coordinator.cjs" -$pidPath = Join-Path $StateDir "odin.pid" -$outLog = Join-Path $StateDir "odin.out.log" -$errLog = Join-Path $StateDir "odin.err.log" -$nodeExe = (Get-Command node.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source) -if (-not $nodeExe) { - $nodeExe = "C:\Program Files\nodejs\node.exe" -} -if (-not (Test-Path -LiteralPath $nodeExe)) { - throw "Node.js executable is missing; checked PATH and $nodeExe" -} - -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Odin Idunn health publication requires -IdunnRudpHealth, ODIN_IDUNN_RUDP_HEALTH, or IDUNN_RUDP_HEALTH; no localhost default is assumed." -} - -New-Item -ItemType Directory -Force -Path $StateDir | Out-Null - -if (Test-Path $pidPath) { - $oldPid = (Get-Content -Raw $pidPath).Trim() - if ($oldPid -match "^\d+$") { - $old = Get-Process -Id ([int] $oldPid) -ErrorAction SilentlyContinue - if ($old) { - throw "Odin already appears to be running as PID $oldPid." - } - } - Remove-Item -LiteralPath $pidPath -Force -} - -$env:NODE_PATH = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib\packages") -$args = @( - $scriptPath, - "--stateDir", $StateDir, - "--cultnet-rudp-bind", $CultNetRudpBind, - "--idunn-rudp-health", $IdunnRudpHealth, - "--idunn-daemon", $IdunnDaemon, - "--idunn-health-contract", $IdunnHealthContract -) - -if ($Foreground) { - & $nodeExe @args - exit $LASTEXITCODE -} - -$proc = Start-Process -FilePath $nodeExe -ArgumentList $args -WorkingDirectory $repoRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput $outLog -RedirectStandardError $errLog -$proc.Id | Set-Content -Encoding ASCII -LiteralPath $pidPath -Start-Sleep -Seconds 1 -if ($proc.HasExited) { - $detail = "" - if (Test-Path $outLog) { $detail += Get-Content -Raw $outLog } - if (Test-Path $errLog) { $detail += Get-Content -Raw $errLog } - throw "Odin exited immediately with code $($proc.ExitCode).`n$detail" -} - -Write-Host "Odin started as PID $($proc.Id)." -Write-Host "CultMesh/RUDP document catalog: $CultNetRudpBind" diff --git a/scripts/stop-odin.ps1 b/scripts/stop-odin.ps1 deleted file mode 100644 index cfbb412..0000000 --- a/scripts/stop-odin.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -param( - [string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin") -) - -$ErrorActionPreference = "Stop" - -$pidPath = Join-Path $StateDir "odin.pid" -if (-not (Test-Path $pidPath)) { - Write-Host "No Odin PID file found at $pidPath." - exit 0 -} - -$odinPid = (Get-Content -Raw $pidPath).Trim() -if ($odinPid -match "^\d+$") { - $process = Get-Process -Id ([int] $odinPid) -ErrorAction SilentlyContinue - if ($process) { - Stop-Process -Id $process.Id - Write-Host "Stopped Odin PID $odinPid." - } -} - -Remove-Item -LiteralPath $pidPath -Force From 99f9a22fa422b8043164f34048bc98ef7c6fc5fe Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 00:22:45 +0200 Subject: [PATCH 04/34] odin-core: keep the cultnet-rs dependency (cultmesh_documents! expands to it) The previous commit dropped cultnet-rs from odin-core's manifest as unused; cultmesh_documents! emits cultnet_rs paths, so the crate needs it in scope. Restore the dependency and its lockfile entry. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/odin-core/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index dc99c86..b4c03b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,6 +589,7 @@ dependencies = [ "anyhow", "cultcache-rs", "cultmesh-rs", + "cultnet-rs", "pretty_assertions", "serde", "serde_json", diff --git a/crates/odin-core/Cargo.toml b/crates/odin-core/Cargo.toml index f7a0cc8..76d2955 100644 --- a/crates/odin-core/Cargo.toml +++ b/crates/odin-core/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" anyhow = "1" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultcache-rs" } cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultmesh-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultnet-rs" } serde_json = "1" serde = { version = "1", features = ["derive"] } From 779c24ba806f91d16495d5ff1063590d52f96369 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 00:23:07 +0200 Subject: [PATCH 05/34] package.json: drop scripts that named the deleted coordinator and a missing crate start (node src/odin-coordinator.cjs), check and test (JS gates over the deleted src/ and test/), muninn:build and muninn:start (muninn-daemon is not in this workspace). gjallar:persona and rust:test stay. This belonged in the Node coordinator deletion commit and was left unstaged there. Co-Authored-By: Claude Sonnet 5.5 --- package.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/package.json b/package.json index 29dc0a5..7fc90b1 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,7 @@ "scripts": { "gjallar:persona": "node scripts/write-gjallar-persona-cc.mjs inspect", "gjallar:persona:write": "node scripts/write-gjallar-persona-cc.mjs write", - "muninn:build": "cargo build -p muninn-daemon", - "muninn:start": "cargo run -p muninn-daemon --", - "rust:test": "cargo test --workspace", - "check": "node scripts/check-syntax.cjs", - "test": "node --test test/*.test.cjs", - "start": "node src/odin-coordinator.cjs" + "rust:test": "cargo test --workspace" }, "engines": { "node": ">=20" From 106dfb83271f12adba7ae4afbc753502603c1597 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 00:25:20 +0200 Subject: [PATCH 06/34] Docs: Idunn owns deployment and daemon survival; Odin does discovery only README and architecture.md called Idunn "Odin's keepalive organ", listed crates/idunn-daemon (not in this repo) and the deleted odin-core idunn.rs as its body, described the Node coordinator's process lifecycle, refresh loop and Docker/ADB probes as Odin's runtime body, and pointed at the deleted start-odin.ps1. Rewritten to the live truth: Idunn builds, activates, restarts and brakes every target, Odin included; odin-daemon reads Idunn's projection, admits presences, publishes correlation records and answers RUDP requests; process lifecycle is not Odin's. The dated ledger (transport-shortcut-inventory.md), notes/ and the Hermodr extraction plan are history and are left as written. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 101 ++++++++++++------------------------------- docs/architecture.md | 90 ++++++++++++-------------------------- 2 files changed, 54 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index dae8ff5..1d295f4 100644 --- a/README.md +++ b/README.md @@ -70,75 +70,47 @@ Local package surfaces: ## Idunn -Idunn is Odin's keepalive organ: the daemon package that should keep the known -swarm alive after Odin has accepted where each daemon lives and what authority -path may touch it. Individual daemons publish health and command boundaries; -Idunn brings them up after reboots or crashes, watches health, and escalates -operator-needed failures through Bifrost's CultMesh bridge. VoidBot owner-DM -delivery is a demoted compatibility actuator, not the owner; the command belongs -in Bifrost's Verse. Odin sees the daemons; Idunn keeps the apples from rotting. -Agents do not deploy daemons directly. They configure Idunn's target catalog, -release targets, migration commands, and command boundaries so Idunn can run -the shared rollout primitive and leave typed witnesses behind. - -Deployment consequence has one final owner: immediately before every migration, -deploy, or restart process spawn, Idunn re-opens the root-owned deployment-brake -CultCache and the dedicated operator public anchor. A released record must bind -the canonical runtime (`yggdrasil` for `yggdrasil-local`), exact source revision -(or `restart:`), exact request id, operator, signature, and short expiry. -Missing, corrupt, engaged, expired, foreign, wrong-scope, substituted, or -badly-signed state fails closed. Planning and lifecycle-command publication do -not bypass or cache this decision. The store and its sibling `.lock` live in -`/var/lib/gamecult/idunn-authority`, a root-owned `root:idunn` `0750` directory; -both files are `root:idunn` `0640`, and the daemon's systemd sandbox exposes the -directory read-only. Idunn holds the lock shared from its final snapshot read -through process spawn, so an engage cannot be defeated by previously captured -released bytes. A release is not one-shot: its exact request/revision grant -covers each migration and deploy spawn in that rollout until its short expiry; -every spawn independently reopens and validates the same grant. +Idunn (`GameCult/Idunn`) owns deployment and daemon survival for every GameCult +target, Odin included: it builds from the target's recipe, activates and +restarts the admitted incarnation, and holds the deployment and lifecycle +brakes. Odin owns none of that. Odin does discovery, schema awareness, +rendezvous, and interface aggregation, and it reads Idunn's topology projection +to learn which incarnations exist. Agents do not deploy daemons directly: they +configure the target's recipe and binding and let Idunn run the transaction. Local package surfaces: -- Organ contract: `docs/idunn.md` -- User README: `src/Idunn/README.md` -- Rust daemon crate: `crates/idunn-daemon` -- Rust lifecycle logic: `crates/odin-core/src/idunn.rs` -- Runtime store: `scratch/idunn/idunn.keepalive.cc` -- Hosted VoidBot release: upstream `main` -> Yggdrasil-local Idunn -> the - root-owned `/srv/odin/deploy-manifests/voidbot` actuator. Local checkouts are - development and diagnostics only; do not create a local deployment. -- Hosted Odin release: branch `codex/ygg-idunn-independent-bootstrap` plus the - exact CultLib commit in `deploy/cultlib.commit` -> one immutable - `/srv/odin/releases/` body -> `/srv/odin/current`. Odin signs its own - liveness to Idunn, while Idunn starts independently and owns later restart or - deployment actuation. -- Operator escalation: Bifrost-owned CultMesh crossing; current compatibility - delivery calls `scripts\notify-idunn-operator-alarm.ps1`, which asks Bifrost - to publish a typed `gamecult.operator_dm_request.v1` CultMesh command document - only after Idunn raises an alarm +- Deployment recipe: `deployment/idunn/recipe.toml` +- Operator binding: `/etc/gamecult/idunn/bindings/odin.toml` on the host, not in + this repository +- Live deployment and admission map: `state/map.yaml` +- Idunn's contract, brakes, and runbooks: the `GameCult/Idunn` repository ## Authority Map - Owner: Odin owns the network-wide Verse registry, schema catalog index, translation map, and accepted provider catalog/proxy surfaces. - Inputs: CultMesh/CultNet peer announcements, schema catalog responses, daemon - health/provider publications over `cultnet.transport.rudp.v0`, local - Docker/ADB debug facts, and provider-owned Eve/CultUI surfaces. + health/provider publications over `cultnet.transport.rudp.v0`, Idunn's + read-only topology projection, and provider-owned Eve/CultUI surfaces. - Outputs: CultCache-backed Odin state, CultMesh documents, and CultNet schema/catalog messages. Browser, GUI, TUI, and framebuffer renderers lower those documents outside Odin instead of asking Odin to host web surfaces. - Derived state: Gjallar's aggregate surface is derived from Odin/provider state; Eve clients derive pixels from that aggregate. - Forbidden writers: renderers do not probe the network or decide Verse truth; individual projects do not maintain private incompatible discovery ledgers once Odin can see them. - Shared paths: human dashboards, worker schedulers, Verse bootstrap code, and compact TUI views consume the same registry and schema catalog. -- Deletion line: old per-host coordinator scripts should be deleted or reduced to deployment wrappers that start Odin. +- Not Odin's: process lifecycle, restart, deployment, and daemon survival belong to Idunn. -## Run Locally On Starfire +## Runtime Body -```powershell -.\scripts\start-odin.ps1 -IdunnRudpHealth $env:IDUNN_RUDP_HEALTH -``` +Odin runs as `odin-daemon` under Idunn on Yggdrasil, built from +`deployment/idunn/recipe.toml`. There is no local start script. The daemon: -`-IdunnRudpHealth`, `ODIN_IDUNN_RUDP_HEALTH`, or `IDUNN_RUDP_HEALTH` must name -the Idunn RUDP health endpoint. Odin does not assume a localhost health target. +- reads Idunn's read-only projection to learn which incarnations of which + targets exist; +- admits provider presences over RUDP for those incarnations and persists them + through CultMesh/CultCache; +- publishes signed runtime-topology correlation records; +- answers CultNet/RUDP snapshot and document requests on its route. Odin's native document catalog is addressed by CultMesh URI. Concrete RUDP bootstrap is configured behind CultMesh URI resolution by the operator or by @@ -152,28 +124,9 @@ That URI accepts typed document publication and schema/catalog requests through the shared CultMesh runtime. Consumers that need Odin's accepted surface can still request the current CultNet snapshot after CultMesh resolves the transport. -Legacy browser/deck lowerers must consume Odin's CultMesh state through their -own lowering process. Odin no longer hosts browser-deck surfaces or publishes -deck URLs as discovery seed material. - -State and logs live under ignored `scratch/odin/`. - -## Current First Body - -The first executable is deliberately narrow: - -- publishes provider catalog `odin.providers`; -- persists the latest surface through local CultMesh/CultCache when `CultLib` packages are available at `E:\Projects\CultLib\packages`; -- writes `scratch/odin/latest-surface.json` only when `--write-debug-surface-json` or `ODIN_WRITE_DEBUG_SURFACE_JSON=1` is explicitly supplied; -- observes Starfire Docker and Periwinkle ADB as local debug/edge facts; -- derives remote Verse presence from provider-owned CultMesh/CultNet - advertisements and interface records, not TCP/SSH/systemd probes. -- publishes explicit `verse` and `service` nodes for compact Eve/CultUI lowerers. -- ingests provider-owned Eve/CultUI dashboards, including `mimir.live.stats` and `voidbot.swarm`, and embeds them as Odin `interface` nodes; -- accepts live `gamecult.eve.provider_advertisement.v1` announcements through Odin's CultMesh/RUDP rendezvous path so daemons can announce schemas, surfaces, commands, nested Verses, and style capabilities without Odin scraping private dashboards; -- accepts explicit local debug imports only when `--interfaceBindingStore` / `ODIN_INTERFACE_BINDING_STORES` entries are written as `cultmesh-store:file://...` URIs; raw filesystem paths are not discovery configuration; -- preserves provider semantic addresses such as `asgard.starfire.bifrost/eve/tui` and `asgard.starfire.bifrost/eve/gui`, with CultNet routes carried as transport metadata rather than identity; -- persists operator tiling intent as `odin.interface_layout.v1` in the Odin CultMesh store; ignored `scratch/odin/interface-layout.json` is migration input only. +Browser and deck lowerers consume Odin's CultMesh state through their own +lowering process. Odin does not host browser-deck surfaces or publish deck URLs +as discovery seed material. Provider advertisements and CultNet/RUDP transport profiles are the discovery path. External host probes, product health checks, port probes, and renderer diff --git a/docs/architecture.md b/docs/architecture.md index 9f69cee..28080d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,35 +46,24 @@ The first Rust core lives in `crates/odin-core`: - `repository.rs`: `OdinRepository` abstraction, in-memory mock repository, and CultMesh-backed repository. -The Rust spine owns the future architecture. The CommonJS daemon remains the -legacy operational body until each organ crosses this typed boundary. +The deployed body is `crates/odin-daemon`, built and run by Idunn from +`deployment/idunn/recipe.toml`. `crates/odin-core` holds the typed record spine +that the rest of the machine grows into. ## Runtime Body Odin's executable body is split by ownership: -- `crates/odin-core`: Rust target core. Owns typed Odin documents, ingest - ports, normalization, and CultMesh/CultCache repository boundaries. This is - the replacement spine; JavaScript remains legacy runtime scaffolding until - each organ has crossed the typed boundary. -- `src/odin-coordinator.cjs`: process lifecycle, serialized refresh loop, - persistence, health, and transport wiring. Refreshes must not overlap because - a refresh publishes Odin's daemon health. -- `src/odin/config.cjs`: runtime paths, refresh intervals, and CultLib module path setup. -- `src/odin/documents.cjs`: CultCache/CultMesh document definitions accepted by Odin. -- `src/odin/idunn-rudp.cjs`: daemon-owned Odin provider health publication to - Idunn over the canonical CultNet RUDP `schema` channel. -- `src/odin/probes.cjs`: local Docker/ADB debug lowerings for Starfire and - Android edge visibility. Remote Verse health is not probed here; it comes - from provider-owned CultMesh/CultNet records. -- `src/odin/interfaces.cjs`: provider advertisements, CultMesh interface - bindings, accepted interface projection, and renderer-lowering metadata. - Renderer routes are lowerings outside Odin, not Odin-hosted transport. -- `src/odin/layout.cjs`: `odin.interface_layout.v1` read/write and merge policy. -- `src/odin/marquee.cjs`: canonical marquee tape assembly from Stonks securities and ordered VoidBot poem lines. -- `src/odin/surface.cjs`: `gamecult.eve.surface.v1` tree projection. -- `src/odin/state.cjs`: one refresh's input records into Odin's provider catalog/proxy state. -The entrypoint is not allowed to grow new probe, surface, provider, layout, or renderer policy. If a new owner is needed, name the owner and its invariant before adding code. +- `crates/odin-daemon`: the deployed process. Reads Idunn's read-only topology + projection, admits provider presences over RUDP for the incarnations Idunn + projects, persists them through CultMesh/CultCache, publishes signed + runtime-topology correlation records, and answers CultNet/RUDP snapshot and + document requests. Deployment, restart, and survival of this process are + Idunn's. +- `crates/odin-core`: typed Odin documents, ingest ports, normalization, and + CultMesh/CultCache repository boundaries. + +If a new owner is needed, name the owner and its invariant before adding code. The accepted document registry is also the persistence admission boundary. Unknown schemas may be observed for diagnostics but are not silently persisted @@ -94,19 +83,15 @@ the typed `gjallar.overview` Eve surface. Eve clients own graphical, terminal, and framebuffer lowering. Gjallar must not own the underlying registry, probe, provider truth, client pixels, or translation decisions. -Idunn is the named keepalive organ for daemon continuity. Its current Rust -body lives in `crates/idunn-daemon` and `crates/odin-core/src/idunn.rs`. Idunn may read -Odin-owned service records and provider advertisements, then bring daemons up -after reboots or crashes, watch health, emit keepalive observations, restart -requests, denied-action records, and operator alarms. When human action is -needed, Idunn uses CultMesh to request a Bifrost-owned operator -notification crossing. VoidBot's `voidbot.operator-dm` command `owner.dm.send` -is a demoted compatibility delivery actuator, not the owner. The target command -lives in Bifrost's Verse; any still-VoidBot delivery path must be invoked by -Bifrost or documented as migration debt. Idunn must not own Verse discovery, -schema truth, provider dashboards, identity grants, Discord delivery, owner-DM -delivery, or renderer layout. Keepalive loops belong in Idunn, not Odin's -coordinator or individual daemons. +Idunn (`GameCult/Idunn`) owns deployment and daemon survival, including Odin's. +It builds from each target's recipe, activates and restarts the admitted +incarnation, and holds the deployment and lifecycle brakes. Odin publishes what +it can see and reads Idunn's topology projection; it does not start, restart, +deploy, or health-probe any daemon, and no Odin code path keeps a process alive. +When human action is needed, Idunn uses CultMesh to request a Bifrost-owned +operator notification crossing. Idunn must not own Verse discovery, schema +truth, provider dashboards, identity grants, Discord delivery, owner-DM +delivery, or renderer layout. Muninn is the portable local telemetry Verse assembler. Its Rust body lives in the `GameCult/Muninn` repository and publishes `muninn.telemetry_surface.v1` @@ -213,19 +198,10 @@ This is not a reporting nicety. It is how Odin prevents services from becoming private little islands with separate websites, dashboards, state formats, and separate command languages. -## Current Service Surface +## Service Surface -Odin currently publishes service squares for: - -- Starfire: Odin, Docker, ADB, Odin CultCache, and running Docker containers. -- Nightwing: SSH, Eve broker, Eve browser reference, visible TUI, Docker unit state, and NVIDIA GPU state. -- EVE: SSH/native Eve reachability. -- Periwinkle: ADB reachability. -- Periwinkle: local ADB edge status plus any provider-owned surface or advertisement Odin receives through CultMesh/Odin discovery. -- Raven: SSH reachability. -- Yggdrasil: compatibility host reachability plus nginx, - StreamPixels, Heimdall, Repixelizer, and Bifrost systemd state until those - daemon surfaces publish health and command boundaries over CultNet RUDP. +Odin's service records come from provider-owned presences and advertisements +received through CultMesh/RUDP, not from host probes. Gjallar consumes Odin's accepted CultMesh/Eve state and composes every visible provider surface into one typed aggregate on Yggdrasil. EveCanvas, browser, and @@ -250,8 +226,8 @@ interactive language; they are not separate dashboard products. Huginn's `.cc` inspection surface is the current clean example: Huginn inspects CultCache bytes and emits Eve DSL, while Eve or any other runtime owns presentation. -Provider advertisements are the promotion path out of probing. Odin's CJS -document set already accepts `gamecult.eve.provider_advertisement.v1` alongside +Provider advertisements are the promotion path out of probing. Odin's document +registry already accepts `gamecult.eve.provider_advertisement.v1` alongside `gamecult.eve.interface_binding.v1` and `gamecult.eve.surface_state.v1`. Daemons should publish advertisements that name service id, Verse id, schema catalog, `.cc` witnesses, Eve surface keys, command boundaries, nested Verses, @@ -290,18 +266,6 @@ identity. The canonical contract lives in `E:\Projects\Eve\docs\provider-advertisement-contract.md`. -Odin persists operator layout intent as `odin.interface_layout.v1` in the Odin CultMesh store under record key `odin.providers`. The ignored `scratch/odin/interface-layout.json` file is migration input only; Odin no longer writes layout truth back to that sidecar. Layout intents name the provider id and request focus, move, resize, or visibility changes; renderers are input devices for those intents, not local layout owners. - -Odin now derives dense top-level layout intent from each provider's retained -`surface.root` tree: element count, leaf count, branch count, depth, text-cell -pressure, and list-like branches. Provider explicit preferred sizes are capped -at the Odin wrapper boundary unless the current intent is fullscreen, so stale -layout files cannot keep empty panels huge. The renderer should use -`props.tree`, `props.layout.signalWeight`, and `props.packing` to allocate space -to nested signal, then recursively lower provider children. Flattening a -provider surface into one log/list is a compatibility failure when the retained -tree has children. - ## Observation Surface Odin no longer tails Mimir JSONL artifacts for observation truth. Device and From 59667f464868a0afcfb3a2b501e350bb011851b3 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 01:54:48 +0200 Subject: [PATCH 07/34] Odin tests: Soul's probes P1-P3 for catalog collision, one bad record, and undecodable own anchor P1 two incarnations of one target, P2 one undecodable presence, P3 an undecodable own provider anchor. All three fail at c34d167; the fixes follow. Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/main.rs | 106 +++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index a05d06c..4e0edbc 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -2038,6 +2038,112 @@ mod tests { Ok(()) } + fn soul_show(label: &str, result: &Result) { + match result { + Ok(value) => eprintln!("SOULPROBE {label}: Ok({value:?})"), + Err(error) => eprintln!( + "SOULPROBE {label}: Err(refused_marker={}, lease_lost={}) {error:#}", + error.downcast_ref::().is_some(), + error.downcast_ref::().is_some() + ), + } + } + + /// Soul P1: a candidate incarnation of the same target, as Idunn projects + /// it during every deployment of that target. + #[test] + fn soul_p1_two_incarnations_of_one_target() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + let mut candidate = odin.state.borrow().authority_material.expected.clone(); + candidate.incarnation_id = "odin/generation-2".into(); + candidate.sealed_release_id = digest('9'); + candidate.validate()?; + odin.append_projection(vec![CultCacheEnvelope { + key: IncarnationRef::of(&candidate)?.key(), + r#type: IdunnExpectedIncarnationRecord::TYPE.into(), + payload: candidate.canonical_bytes()?, + stored_at: rfc3339_millis(unix_millis()?)?, + schema_id: Some(IDUNN_EXPECTED_INCARNATION_SCHEMA.into()), + }])?; + odin.timers = ServingTimers::default(); + let pass = odin.pass(); + soul_show("P1 pass", &pass); + eprintln!("SOULPROBE P1 correlation keys {:?}", odin.stored_keys(OdinRuntimeTopologyCorrelationRecord::TYPE)?); + let catalog = odin.catalog().map(|documents| documents.len()); + soul_show("P1 whole catalog", &catalog); + let filtered = odin.state.borrow_mut().raw_snapshot(&CultMeshRudpSnapshotQuery { + session: cultmesh_rs::CultMeshRudpSessionKey { remote_addr: "127.0.0.1:1".parse()?, connection_id: 7 }, + message_id: "presence-only".into(), + requested_at_unix_millis: 1, + schema_ids: Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), + record_keys: None, + }).map(|documents| documents.len()); + soul_show("P1 presence-only catalog", &filtered); + assert!(catalog.is_ok(), "SOUL P1: whole catalog refused"); + Ok(()) + } + + /// Soul P2: one presence in Odin's store that does not decode. + #[test] + fn soul_p2_one_undecodable_presence() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + let stamp = rfc3339_millis(unix_millis()?)?; + odin.tamper_store(|mut entries| { + entries.push(CultCacheEnvelope { + key: "ghost".into(), + r#type: GameCultRuntimePresenceHealthRecord::TYPE.into(), + payload: vec![0xc1], + stored_at: stamp.clone(), + schema_id: Some(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()), + }); + entries + })?; + let catalog = odin.catalog().map(|documents| documents.len()); + soul_show("P2 catalog", &catalog); + let startup = prior_self_publisher_sequence(&odin.store, &odin.provider_identity_id); + soul_show("P2 startup sequence read (RuntimeState::open)", &startup); + odin.timers = ServingTimers::default(); + let before = odin.stored_sequence(); + soul_show("P2 stored_sequence", &before); + let pass = odin.pass(); + soul_show("P2 pass", &pass); + assert!(catalog.is_ok() && startup.is_ok(), "SOUL P2: one record broke the catalog or startup"); + Ok(()) + } + + /// Soul P3: Odin's own projected provider anchor does not decode. + #[test] + fn soul_p3_undecodable_own_anchor() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + let before = odin.stored_sequence()?; + odin.tamper_projection(|entries| { + entries + .into_iter() + .map(|mut entry| { + if entry.r#type == GameCultServiceTrustAnchorRecord::TYPE { + entry.payload = vec![0xc1]; + } + entry + }) + .collect() + })?; + let catalog = odin.catalog().map(|documents| documents.len()); + soul_show("P3 catalog", &catalog); + let mut outcomes = Vec::new(); + for _ in 0..3 { + odin.timers = ServingTimers::default(); + let pass = odin.pass(); + soul_show("P3 pass", &pass); + outcomes.push(pass.is_ok()); + } + eprintln!("SOULPROBE P3 self sequence before {before} after {}", odin.stored_sequence()?); + assert!(outcomes.iter().all(|ok| !ok), "SOUL P3: Odin survives an authority it cannot read, presence frozen"); + Ok(()) + } + /// An application rejection ends only the offending peer's session, and a /// stray datagram is dropped; neither ends Odin's serving loop. #[test] From 2e4ccc40ad888bddfad6aaa234242786ead945c0 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 02:01:48 +0200 Subject: [PATCH 08/34] Odin: a bad record is skipped, own-authority failures are fatal, correlations leave the catalog A1 the catalog and startup skip a record that does not decode (logged); startup reads only presences keyed under Odin's own target. A2 every failure to establish Odin's own projected authority is PresenceAuthorityRefused (temporary rule in survive). A3 a clock that precedes the provider anchor's binding is retried, not refused. C the catalog no longer serves correlations; the store still writes them. B test peers connect while the server is polled without timers; 2 s deadline. 6 repeating refresh and catalog-skip lines are logged once per minute per subject. Soul's probes P1-P3 become assertion tests. Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/lib.rs | 81 ++++- crates/odin-daemon/src/main.rs | 614 ++++++++++++++++++++++++--------- 2 files changed, 532 insertions(+), 163 deletions(-) diff --git a/crates/odin-daemon/src/lib.rs b/crates/odin-daemon/src/lib.rs index dfe5112..afdb89e 100644 --- a/crates/odin-daemon/src/lib.rs +++ b/crates/odin-daemon/src/lib.rs @@ -756,6 +756,15 @@ where .context(PresenceAuthorityRefused( "the projected runtime authority cannot be verified", ))?; + // A host whose clock has not reached the anchor's binding (booting + // before time sync) cannot yet tell whether the anchor is good. That is + // not a refusal of the authority, so it must not end Odin: it retries. + if authority.is_none() + && incoming.is_some() + && only_clock_precedes_anchor(&projection, &projection_disagreements, now) + { + bail!("the clock ({now}) precedes the provider trust anchor's binding; retrying"); + } let (lease_sha256, lease_disagreement) = classify_current_lease(&projection)?; let query = OdinStoreQuery { incarnation: IncarnationRef::of(&projection.expected)?, @@ -1274,6 +1283,8 @@ fn dependency_permits_ready(kind: &str, ready: bool) -> bool { matches!(kind, "optional" | "external-operator-binding") || ready } +const PROVIDER_ANCHOR_TIME_CODE: &str = "provider-trust-anchor-time"; + fn classify_runtime_authority( projection: &IdunnRuntimeProjection, idunn_anchor: &ServiceIdentityTrustAnchor, @@ -1334,7 +1345,7 @@ fn classify_runtime_authority( .is_some_and(|expires_at| now >= expires_at) { disagreements.push(OdinTopologyDisagreement { - code: "provider-trust-anchor-time".into(), + code: PROVIDER_ANCHOR_TIME_CODE.into(), expected: Some(format!("current-at:{now}")), observed: Some(format!( "bound:{};expires:{}", @@ -1404,6 +1415,23 @@ fn classify_runtime_authority( Ok((authority, disagreements)) } +/// The projected provider anchor is not yet bound at `now` and nothing else +/// disagrees, so a later reading of the same projection would verify. +fn only_clock_precedes_anchor( + projection: &IdunnRuntimeProjection, + disagreements: &[OdinTopologyDisagreement], + now: u64, +) -> bool { + projection.provider_anchor.as_ref().is_some_and(|anchor| { + anchor.bound_at_unix_millis > now + && !anchor + .expires_at_unix_millis + .is_some_and(|expires_at| now >= expires_at) + }) && disagreements + .iter() + .all(|disagreement| disagreement.code == PROVIDER_ANCHOR_TIME_CODE) +} + fn classify_current_lease( projection: &IdunnRuntimeProjection, ) -> Result<(Option, Option)> { @@ -2558,6 +2586,57 @@ mod tests { Ok(()) } + fn is_refused(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some() + } + + /// A host whose clock has not reached the anchor's binding (booting before + /// time sync) is retried, not refused: the same claim lands once the clock + /// catches up. The anchor is bound at NOW - 100. + #[test] + fn a_clock_before_the_anchors_binding_is_retried_not_refused() -> Result<()> { + let world = TestWorld::new()?; + let service = world.service("ghostlight", Vec::new(), false)?; + let claim = service.signed_presence(1, |_| {})?; + let error = world + .engine(NOW - 200) + .admit_presence("ghostlight", &claim, NOW - 200) + .unwrap_err(); + assert!(!is_refused(&error), "{error:#}"); + world + .engine(NOW) + .admit_presence("ghostlight", &claim, NOW)?; + Ok(()) + } + + /// The clock exemption is only for the binding time. An anchor that has + /// expired, or one that also disagrees with Expected, is a refusal. + #[test] + fn only_a_clock_before_the_binding_is_exempt_from_refusal() -> Result<()> { + let world = TestWorld::new()?; + let mut service = world.service("ghostlight", Vec::new(), false)?; + let claim = service.signed_presence(1, |_| {})?; + + service.projection.provider_anchor.as_mut().unwrap().expires_at_unix_millis = + Some(NOW - 50); + world.publish_projection(&service.projection)?; + let error = world + .engine(NOW) + .admit_presence("ghostlight", &claim, NOW) + .unwrap_err(); + assert!(is_refused(&error), "expired: {error:#}"); + + service.projection.provider_anchor.as_mut().unwrap().expires_at_unix_millis = None; + service.projection.provider_anchor.as_mut().unwrap().runtime_id = "other-runtime".into(); + world.publish_projection(&service.projection)?; + let error = world + .engine(NOW - 200) + .admit_presence("ghostlight", &claim, NOW - 200) + .unwrap_err(); + assert!(is_refused(&error), "early and mismatched: {error:#}"); + Ok(()) + } + fn snapshot_with_stored_presence( service: &TestService, stored: AdmittedPresence, diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index 4e0edbc..d899d59 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -121,6 +121,7 @@ struct RuntimeState { write_lease_path: PathBuf, recent_warming_proofs: VecDeque<(String, u64)>, publisher_sequence: u64, + log_gate: RefCell, } #[derive(Clone)] @@ -130,8 +131,18 @@ struct SinkHandle(Rc>); struct SnapshotHandle(Rc>); impl CultMeshRudpRawDocumentSink for SinkHandle { - fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { - self.0.borrow_mut().accept_raw_document(receipt) + fn accept_raw_ // The catalog serves past the ghost; correlations are not part of it. + let catalog = odin.catalog()?; + assert_eq!( + documents_of(&catalog, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA), + 1 + ); +(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { + self.0.borrow_mut().accept_raw_document(receipt) .ok_or_else(|| -> anyhow::Error { + PresenceAuthorityRefused("Idunn projects no Expected for Odin's own incarnation") + .into() + })?; + Ok(()) } } @@ -217,6 +228,7 @@ impl RuntimeState { write_lease_path: PathBuf::from(required_environment(PROCESS_WRITE_LEASE_ENVIRONMENT)?), recent_warming_proofs: VecDeque::new(), publisher_sequence: store_sequence, + log_gate: RefCell::default(), }) } @@ -419,10 +431,31 @@ impl RuntimeState { /// liveness. fn publish_self_presence(&mut self, detail: &str) -> Result<()> { self.require_current_write_lease()?; + self.require_own_projection()?; let document = self.signed_presence_document("active", detail)?; self.admit_presence_document(&document, unix_millis()?) } + /// Idunn's projection of this process's own incarnation. Every failure to + /// establish it -- unreadable, undecodable, ambiguous, substituted, or + /// absent -- is a failure of Odin's own authority, so it is marked for the + /// temporary fatal rule in `survive`. Failures about other incarnations are + /// not: they are read, logged and skipped where they are met. + fn require_own_projection(&self) -> Result<()> { + CultCacheIdunnProjectionSource::new(&self.options.idunn_projection) + .projection(&self_incarnation(&self.authority_material)) + .map_err(|error| { + error.context(PresenceAuthorityRefused( + "Odin's own projected authority cannot be read", + )) + })? + .ok_or_else(|| -> anyhow::Error { + PresenceAuthorityRefused("Idunn projects no Expected for Odin's own incarnation") + .into() + })?; + Ok(()) + } + fn require_current_write_lease(&self) -> Result<()> { let held = self .write_lease @@ -460,15 +493,21 @@ impl RuntimeState { // read is that incarnation's failure, and never stops the others. for incarnation in incarnations { if let Err(error) = topology.refresh(&incarnation) { - eprintln!( - "Odin could not refresh incarnation {}; the rest are unaffected: {error:#}", - incarnation.key() + self.log_repeating( + &format!("refresh of incarnation {}", incarnation.key()), + format!("failed; the rest are unaffected: {error:#}"), ); } } Ok(()) } + /// The catalog serves what is stored, one record at a time: a record that + /// does not decode, or whose projection cannot be read, is skipped and + /// logged, and is never a reason to refuse every other record. Odin's + /// correlations are not part of the catalog: Idunn reads them from the + /// store file, and a target has one per incarnation, so keying them by + /// target would collide for the whole deploy window. fn stored_snapshot( &self, query: &CultMeshRudpSnapshotQuery, @@ -481,73 +520,16 @@ impl RuntimeState { }; let projections = CultCacheIdunnProjectionSource::new(&self.options.idunn_projection); let mut selected = BTreeMap::new(); - for envelope in entries { - let Some(schema_id) = envelope.schema_id.clone() else { - continue; - }; - let document = if envelope.r#type == GameCultRuntimePresenceHealthRecord::TYPE { - let presence = decode_presence(&envelope.payload)?; - let Some(projection) = projections.projection(&IncarnationRef::new( - presence.target.clone(), - presence.expected_projection_sha256.clone(), - ))? - else { + for envelope in &entries { + let document = match self.public_document(&projections, envelope) { + Ok(Some(document)) => document, + Ok(None) => continue, + Err(error) => { + self.log_repeating( + &format!("catalog skips {} {}", envelope.r#type, envelope.key), + format!("{error:#}"), + ); continue; - }; - if projection.expected.expected_signer_identity_id != presence.signer_identity_id - || projection - .activation - .as_ref() - .map(IdunnRuntimeActivationRecord::canonical_sha256) - .transpose()? - .as_deref() - != Some(presence.activation_witness_sha256.as_str()) - || projection - .activation - .as_ref() - .map(|activation| activation.runtime_instance_id.as_str()) - != Some(presence.runtime_instance_id.as_str()) - { - continue; - } - CultNetRawDocumentRecord { - schema_id, - record_key: presence.target.clone(), - stored_at: envelope.stored_at, - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: envelope.payload, - source_runtime_id: Some(presence.runtime_id), - source_agent_id: Some(presence.signer_identity_id), - source_role: Some("runtime-presence-health-publisher".into()), - tags: Some(vec!["odin-observed".into()]), - } - } else if envelope.r#type == OdinRuntimeTopologyCorrelationRecord::TYPE { - let (correlation, _) = - OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload( - &envelope.payload, - )?; - CultNetRawDocumentRecord { - schema_id, - record_key: correlation.target, - stored_at: envelope.stored_at, - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: envelope.payload, - source_runtime_id: Some(self.authority_material.expected.runtime_id.clone()), - source_agent_id: Some(correlation.signer_identity_id), - source_role: Some("odin-topology-correlation".into()), - tags: Some(vec!["odin-owned".into()]), - } - } else { - CultNetRawDocumentRecord { - schema_id, - record_key: envelope.key, - stored_at: envelope.stored_at, - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: envelope.payload, - source_runtime_id: None, - source_agent_id: None, - source_role: None, - tags: None, } }; if !query_allows(query, &document) { @@ -561,6 +543,132 @@ impl RuntimeState { } Ok(selected.into_values().collect()) } + + /// One stored record as the Verse sees it; `None` when it is not public. + fn public_document( + &self, + projections: &CultCacheIdunnProjectionSource, + envelope: &CultCacheEnvelope, + ) -> Result> { + let Some(schema_id) = envelope.schema_id.clone() else { + return Ok(None); + }; + if envelope.r#type == OdinRuntimeTopologyCorrelationRecord::TYPE { + return Ok(None); + } + if envelope.r#type != GameCultRuntimePresenceHealthRecord::TYPE { + return Ok(Some(CultNetRawDocumentRecord { + schema_id, + record_key: envelope.key.clone(), + stored_at: envelope.stored_at.clone(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: envelope.payload.clone(), + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + })); + } + let presence = decode_presence(&envelope.payload)?; + let Some(projection) = projections.projection(&IncarnationRef::new( + presence.target.clone(), + presence.expected_projection_sha256.clone(), + ))? + else { + return Ok(None); + }; + if projection.expected.expected_signer_identity_id != presence.signer_identity_id + || projection + .activation + .as_ref() + .map(IdunnRuntimeActivationRecord::canonical_sha256) + .transpose()? + .as_deref() + != Some(presence.activation_witness_sha256.as_str()) + || projection + .activation + .as_ref() + .map(|activation| activation.runtime_instance_id.as_str()) + != Some(presence.runtime_instance_id.as_str()) + { + return Ok(None); + } + Ok(Some(CultNetRawDocumentRecord { + schema_id, + record_key: presence.target.clone(), + stored_at: envelope.stored_at.clone(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: envelope.payload.clone(), + source_runtime_id: Some(presence.runtime_id), + source_agent_id: Some(presence.signer_identity_id), + source_role: Some("runtime-presence-health-publisher".into()), + tags: Some(vec!["odin-observed".into()]), + })) + } + + /// Log a line that a persistent fault would otherwise repeat on every pass: + /// once per subject per minute, or as soon as its message changes. + fn log_repeating(&self, subject: &str, message: String) { + if let Some(repeats) = self + .log_gate + .borrow_mut() + .admit(subject, &message, Instant::now()) + { + if repeats == 0 { + eprintln!("Odin {subject}: {message}"); + } else { + eprintln!("Odin {subject}: {message} ({repeats} repeats suppressed)"); + } + } + } +} + +/// Once-per-interval admission for repeating log lines, keyed by subject. +#[derive(Default)] +struct LogGate { + seen: BTreeMap, + suppressed_total: u64, +} + +struct LoggedLine { + at: Instant, + message: String, + suppressed: u64, +} + +const REPEATED_LOG_INTERVAL: Duration = Duration::from_secs(60); +const MAX_LOG_SUBJECTS: usize = 1024; + +impl LogGate { + /// `Some(repeats suppressed since the last line)` when the line is to be + /// written, `None` when it repeats one written less than the interval ago. + fn admit(&mut self, subject: &str, message: &str, now: Instant) -> Option { + if let Some(line) = self.seen.get_mut(subject) + && line.message == message + && now.saturating_duration_since(line.at) < REPEATED_LOG_INTERVAL + { + line.suppressed += 1; + self.suppressed_total += 1; + return None; + } + let repeats = self.seen.remove(subject).map_or(0, |line| line.suppressed); + if self.seen.len() >= MAX_LOG_SUBJECTS { + self.seen + .retain(|_, line| now.saturating_duration_since(line.at) < REPEATED_LOG_INTERVAL); + if self.seen.len() >= MAX_LOG_SUBJECTS { + self.seen.clear(); + } + } + self.seen.insert( + subject.to_owned(), + LoggedLine { + at: now, + message: message.to_owned(), + suppressed: 0, + }, + ); + Some(repeats) + } } fn main() -> Result<()> { @@ -686,12 +794,15 @@ impl std::error::Error for WriteLeaseLost {} /// refresh, publication or read is logged and retried on the next pass, never /// allowed to end the daemon. Only `WriteLeaseLost`, wherever in the error chain /// it sits, is returned -- and, as a named temporary rule, so is -/// `PresenceAuthorityRefused`: a self-presence that Odin's own authority will -/// never admit (no verifiable authority, a signer that does not match the -/// anchor, a stored presence of this activation that no longer authenticates) +/// `PresenceAuthorityRefused`: a failure to establish Odin's OWN authority +/// (`require_own_projection`), or a self-presence that authority will never +/// admit (no verifiable authority, a signer that does not match the anchor, a +/// stored presence of this activation that no longer authenticates) /// leaves a frozen presence that goes stale, and a stale Odin presence makes /// `dependency_evidence` flip every dependent to not-Ready. Ending Odin lets -/// Idunn replace it. Deleted when Idunn proves Odin by its own route challenge +/// Idunn replace it. A host clock that has not yet reached the anchor's binding +/// is not such a failure (`reconcile`): it is retried. Deleted when Idunn +/// proves Odin by its own route challenge /// (Idunn audit cut A3, operator question Q-O5). fn survive(what: &str, result: Result) -> Result> { match result { @@ -1058,24 +1169,37 @@ fn read_process_write_lease(path: &Path) -> Result Result { if !path.is_file() { return Ok(0); } - SingleFileMessagePackBackingStore::new(path) + Ok(SingleFileMessagePackBackingStore::new(path) .pull_all_read_only_snapshot()? .into_iter() - .filter(|entry| entry.r#type == GameCultRuntimePresenceHealthRecord::TYPE) - .try_fold(0, |maximum, entry| { - let presence = decode_presence(&entry.payload)?; - Ok( - if presence.target == TARGET && presence.signer_identity_id == signer_identity_id { - maximum.max(presence.publisher_sequence) - } else { - maximum - }, - ) + .filter(|entry| { + entry.r#type == GameCultRuntimePresenceHealthRecord::TYPE + && entry.key.starts_with(TARGET) }) + .filter_map(|entry| match decode_presence(&entry.payload) { + Ok(presence) => Some(presence), + Err(error) => { + eprintln!( + "Odin startup skipped its stored presence {}: {error:#}", + entry.key + ); + None + } + }) + .filter(|presence| { + presence.target == TARGET && presence.signer_identity_id == signer_identity_id + }) + .map(|presence| presence.publisher_sequence) + .max() + .unwrap_or(0)) } fn persist_generic_document(path: &Path, document: &CultNetRawDocumentRecord) -> Result<()> { @@ -1528,6 +1652,7 @@ mod tests { write_lease_path: lease_path.clone(), recent_warming_proofs: VecDeque::new(), publisher_sequence: 0, + log_gate: RefCell::default(), }; // The lease names the Warming presence Odin signed for Idunn's probe. @@ -1692,15 +1817,17 @@ mod tests { prior_self_publisher_sequence(&self.store, &self.provider_identity_id) } - /// Run a peer on its own thread while Odin serves. Every pass Odin - /// takes meanwhile must succeed. + /// Run a peer on its own thread while Odin polls its socket, and nothing + /// else. The timed work of a pass (refresh and heartbeat, both fsync-heavy) + /// would leave the peers waiting past their connect deadline on a loaded + /// host; the tests take the timed passes themselves afterwards. fn serve_while( &mut self, peer: impl FnOnce() -> T + Send + 'static, ) -> Result { let peer = thread::spawn(peer); while !peer.is_finished() { - if !self.pass()? { + if !poll_server(&mut self.server, &mut self.timers.poll_failing_since)? { thread::sleep(IDLE_POLL_INTERVAL); } } @@ -1723,7 +1850,7 @@ mod tests { CultNetRudpSocketTransportOptions::client("test-peer", socket, target, connection_id), )?; transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_millis(500); + let deadline = Instant::now() + Duration::from_secs(2); while !transport.connected() { let _ = transport.receive_once()?; transport.poll_resends()?; @@ -1918,12 +2045,11 @@ mod tests { "the good incarnation was refreshed past the ghost" ); + // The catalog serves past the ghost; correlations are not part of it. let catalog = odin.catalog()?; - assert!( - catalog - .iter() - .any(|document| document.schema_id == ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA), - "the catalog serves its correlations" + assert_eq!( + documents_of(&catalog, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA), + 1 ); Ok(()) } @@ -2038,87 +2164,136 @@ mod tests { Ok(()) } - fn soul_show(label: &str, result: &Result) { - match result { - Ok(value) => eprintln!("SOULPROBE {label}: Ok({value:?})"), - Err(error) => eprintln!( - "SOULPROBE {label}: Err(refused_marker={}, lease_lost={}) {error:#}", - error.downcast_ref::().is_some(), - error.downcast_ref::().is_some() - ), - } + fn generic_document() -> Result { + Ok(CultCacheEnvelope { + key: "doc-1".into(), + r#type: "ghostlight.doc".into(), + payload: vec![1], + stored_at: rfc3339_millis(unix_millis()?)?, + schema_id: Some("ghostlight.doc.v1".into()), + }) } - /// Soul P1: a candidate incarnation of the same target, as Idunn projects - /// it during every deployment of that target. - #[test] - fn soul_p1_two_incarnations_of_one_target() -> Result<()> { - let mut odin = activated_odin()?; - odin.pass()?; + fn documents_of(documents: &[CultNetRawDocumentRecord], schema: &str) -> usize { + documents + .iter() + .filter(|document| document.schema_id == schema) + .count() + } + + /// A candidate incarnation of Odin, as Idunn projects it beside the + /// incumbent for the whole deploy window. + fn project_candidate_incarnation(odin: &OdinWorld) -> Result { let mut candidate = odin.state.borrow().authority_material.expected.clone(); candidate.incarnation_id = "odin/generation-2".into(); candidate.sealed_release_id = digest('9'); candidate.validate()?; + let incarnation = IncarnationRef::of(&candidate)?; odin.append_projection(vec![CultCacheEnvelope { - key: IncarnationRef::of(&candidate)?.key(), + key: incarnation.key(), r#type: IdunnExpectedIncarnationRecord::TYPE.into(), payload: candidate.canonical_bytes()?, stored_at: rfc3339_millis(unix_millis()?)?, schema_id: Some(IDUNN_EXPECTED_INCARNATION_SCHEMA.into()), }])?; - odin.timers = ServingTimers::default(); - let pass = odin.pass(); - soul_show("P1 pass", &pass); - eprintln!("SOULPROBE P1 correlation keys {:?}", odin.stored_keys(OdinRuntimeTopologyCorrelationRecord::TYPE)?); - let catalog = odin.catalog().map(|documents| documents.len()); - soul_show("P1 whole catalog", &catalog); - let filtered = odin.state.borrow_mut().raw_snapshot(&CultMeshRudpSnapshotQuery { - session: cultmesh_rs::CultMeshRudpSessionKey { remote_addr: "127.0.0.1:1".parse()?, connection_id: 7 }, - message_id: "presence-only".into(), - requested_at_unix_millis: 1, - schema_ids: Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), - record_keys: None, - }).map(|documents| documents.len()); - soul_show("P1 presence-only catalog", &filtered); - assert!(catalog.is_ok(), "SOUL P1: whole catalog refused"); - Ok(()) + Ok(incarnation) } - /// Soul P2: one presence in Odin's store that does not decode. + /// Idunn projects the incumbent and the candidate of a target for the whole + /// deploy window, and Odin writes a correlation for each. The catalog does + /// not serve correlations, so two of one target cannot collide in it, and + /// the presence queries every consumer makes are unaffected. #[test] - fn soul_p2_one_undecodable_presence() -> Result<()> { + fn two_incarnations_of_one_target_leave_the_catalog_readable() -> Result<()> { let mut odin = activated_odin()?; odin.pass()?; - let stamp = rfc3339_millis(unix_millis()?)?; - odin.tamper_store(|mut entries| { - entries.push(CultCacheEnvelope { - key: "ghost".into(), - r#type: GameCultRuntimePresenceHealthRecord::TYPE.into(), - payload: vec![0xc1], - stored_at: stamp.clone(), - schema_id: Some(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()), - }); - entries - })?; - let catalog = odin.catalog().map(|documents| documents.len()); - soul_show("P2 catalog", &catalog); - let startup = prior_self_publisher_sequence(&odin.store, &odin.provider_identity_id); - soul_show("P2 startup sequence read (RuntimeState::open)", &startup); + let candidate = project_candidate_incarnation(&odin)?; odin.timers = ServingTimers::default(); - let before = odin.stored_sequence(); - soul_show("P2 stored_sequence", &before); - let pass = odin.pass(); - soul_show("P2 pass", &pass); - assert!(catalog.is_ok() && startup.is_ok(), "SOUL P2: one record broke the catalog or startup"); + odin.pass()?; + + // Idunn reads the correlations from the store, one per incarnation. + let keys = odin.stored_keys(OdinRuntimeTopologyCorrelationRecord::TYPE)?; + assert_eq!(keys.len(), 2, "{keys:?}"); + assert!(keys.contains(&candidate.key())); + + let catalog = odin.catalog()?; + assert_eq!( + documents_of(&catalog, ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA), + 0 + ); + assert_eq!( + documents_of(&catalog, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA), + 1 + ); + let presence_only = odin + .state + .borrow_mut() + .raw_snapshot(&CultMeshRudpSnapshotQuery { + session: cultmesh_rs::CultMeshRudpSessionKey { + remote_addr: "127.0.0.1:1".parse()?, + connection_id: 7, + }, + message_id: "presence-only".into(), + requested_at_unix_millis: 1, + schema_ids: Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), + record_keys: None, + })?; + assert_eq!(presence_only.len(), 1); Ok(()) } - /// Soul P3: Odin's own projected provider anchor does not decode. + /// One record that does not decode is skipped where it is met: the catalog + /// still serves everything else, and startup still finds Odin's sequence. #[test] - fn soul_p3_undecodable_own_anchor() -> Result<()> { + fn records_that_do_not_decode_never_stop_the_catalog_or_startup() -> Result<()> { let mut odin = activated_odin()?; odin.pass()?; let before = odin.stored_sequence()?; + assert!(before > 0); + let stamp = rfc3339_millis(unix_millis()?)?; + let own_signer = odin.provider_identity_id.clone(); + odin.tamper_store(|mut entries| { + for key in [ + "ghost".to_owned(), + format!("{TARGET}@{}/{own_signer}", digest('c')), + ] { + entries.push(CultCacheEnvelope { + key, + r#type: GameCultRuntimePresenceHealthRecord::TYPE.into(), + payload: vec![0xc1], + stored_at: stamp.clone(), + schema_id: Some(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()), + }); + } + entries.push(generic_document().unwrap()); + entries + })?; + + let catalog = odin.catalog()?; + assert_eq!( + documents_of(&catalog, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA), + 1, + "Odin's own presence is served" + ); + assert_eq!(documents_of(&catalog, "ghostlight.doc.v1"), 1); + assert_eq!( + prior_self_publisher_sequence(&odin.store, &odin.provider_identity_id)?, + before, + "the undecodable presences do not change the sequence Odin continues from" + ); + Ok(()) + } + + /// A presence whose incarnation's projection cannot be read is skipped as + /// that record's failure; the rest of the catalog is still served. + #[test] + fn a_presence_whose_projection_cannot_be_read_is_skipped_not_fatal() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + odin.tamper_store(|mut entries| { + entries.push(generic_document().unwrap()); + entries + })?; odin.tamper_projection(|entries| { entries .into_iter() @@ -2130,20 +2305,135 @@ mod tests { }) .collect() })?; - let catalog = odin.catalog().map(|documents| documents.len()); - soul_show("P3 catalog", &catalog); - let mut outcomes = Vec::new(); - for _ in 0..3 { - odin.timers = ServingTimers::default(); - let pass = odin.pass(); - soul_show("P3 pass", &pass); - outcomes.push(pass.is_ok()); - } - eprintln!("SOULPROBE P3 self sequence before {before} after {}", odin.stored_sequence()?); - assert!(outcomes.iter().all(|ok| !ok), "SOUL P3: Odin survives an authority it cannot read, presence frozen"); + let catalog = odin.catalog()?; + assert_eq!(catalog.len(), 1, "{catalog:?}"); + assert_eq!(catalog[0].schema_id, "ghostlight.doc.v1"); Ok(()) } + /// Every failure to establish Odin's OWN authority ends Odin (temporary + /// rule, see `survive`), whatever shape the failure takes: an anchor that + /// does not decode or names another schema, an Expected Idunn no longer + /// projects, an activation or lease that does not decode. + #[test] + fn every_failure_to_establish_its_own_authority_ends_odin() -> Result<()> { + type Tamper = fn(CultCacheEnvelope) -> Option; + let cases: [(&str, Tamper); 5] = [ + ("anchor does not decode", |mut entry| { + if entry.r#type == GameCultServiceTrustAnchorRecord::TYPE { + entry.payload = vec![0xc1]; + } + Some(entry) + }), + ("anchor names another schema", |mut entry| { + if entry.r#type == GameCultServiceTrustAnchorRecord::TYPE { + entry.schema_id = Some("gamecult.service_trust_anchor.v99".into()); + } + Some(entry) + }), + ("activation does not decode", |mut entry| { + if entry.r#type == IdunnRuntimeActivationRecord::TYPE { + entry.payload = vec![0xc1]; + } + Some(entry) + }), + ("lease does not decode", |mut entry| { + if entry.r#type == IdunnProcessWriteLeaseRecord::TYPE { + entry.payload = vec![0xc1]; + } + Some(entry) + }), + ("Expected is no longer projected", |entry| { + (entry.r#type != IdunnExpectedIncarnationRecord::TYPE).then_some(entry) + }), + ]; + for (name, tamper) in cases { + let mut odin = activated_odin()?; + odin.pass()?; + let before = odin.stored_sequence()?; + odin.tamper_projection(|entries| entries.into_iter().filter_map(tamper).collect())?; + for _ in 0..3 { + odin.timers = ServingTimers::default(); + let error = odin + .pass() + .expect_err(&format!("{name}: Odin must not serve a frozen presence")); + assert!( + error.downcast_ref::().is_some(), + "{name}: {error:#}" + ); + } + assert_eq!(odin.stored_sequence()?, before, "{name}"); + } + Ok(()) + } + + /// Another target's broken records are that target's fault: Odin keeps + /// serving, and the failing refresh is logged once, not on every pass. + #[test] + fn another_targets_broken_authority_never_ends_odin_and_logs_once() -> Result<()> { + let mut odin = activated_odin()?; + let mut sibling = odin.state.borrow().authority_material.expected.clone(); + sibling.target = "sibling".into(); + sibling.validate()?; + odin.append_projection(vec![ + CultCacheEnvelope { + key: IncarnationRef::of(&sibling)?.key(), + r#type: IdunnExpectedIncarnationRecord::TYPE.into(), + payload: sibling.canonical_bytes()?, + stored_at: rfc3339_millis(unix_millis()?)?, + schema_id: Some(IDUNN_EXPECTED_INCARNATION_SCHEMA.into()), + }, + CultCacheEnvelope { + key: "root/sibling/runtime-presence".into(), + r#type: GameCultServiceTrustAnchorRecord::TYPE.into(), + payload: vec![0xc1], + stored_at: rfc3339_millis(unix_millis()?)?, + schema_id: Some(GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into()), + }, + ])?; + let suppressed = |odin: &OdinWorld| odin.state.borrow().log_gate.borrow().suppressed_total; + + odin.pass()?; + assert_eq!(suppressed(&odin), 0, "the first failure is logged"); + for repeat in 1..=3 { + odin.timers = ServingTimers::default(); + odin.pass()?; + assert_eq!(suppressed(&odin), repeat, "a repeat is not logged again"); + } + Ok(()) + } + + #[test] + fn a_repeating_line_is_logged_once_per_interval_or_when_it_changes() { + let mut gate = LogGate::default(); + let start = Instant::now(); + assert_eq!(gate.admit("s", "m", start), Some(0)); + assert_eq!( + gate.admit( + "s", + "m", + start + REPEATED_LOG_INTERVAL - Duration::from_millis(1) + ), + None + ); + // A changed message is a new line, and reports what it swallowed. + let later = start + Duration::from_secs(1); + assert_eq!(gate.admit("s", "other", later), Some(1)); + // Subjects are independent. + assert_eq!(gate.admit("t", "other", later), Some(0)); + // The interval is measured from the last line written. + assert_eq!( + gate.admit("s", "other", later + REPEATED_LOG_INTERVAL), + Some(0) + ); + assert_eq!(gate.suppressed_total, 1); + + for index in 0..=2 * MAX_LOG_SUBJECTS { + gate.admit(&format!("subject-{index}"), "m", later); + } + assert!(gate.seen.len() <= MAX_LOG_SUBJECTS); + } + /// An application rejection ends only the offending peer's session, and a /// stray datagram is dropped; neither ends Odin's serving loop. #[test] From 8c33a18729b8774f587b9e0ca6a4092b56c1ccbe Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 02:04:00 +0200 Subject: [PATCH 09/34] Odin: repair a mis-spliced edit in the sink handle (the previous commit did not compile) Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/main.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index d899d59..a8f5c24 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -131,18 +131,8 @@ struct SinkHandle(Rc>); struct SnapshotHandle(Rc>); impl CultMeshRudpRawDocumentSink for SinkHandle { - fn accept_raw_ // The catalog serves past the ghost; correlations are not part of it. - let catalog = odin.catalog()?; - assert_eq!( - documents_of(&catalog, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA), - 1 - ); -(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { - self.0.borrow_mut().accept_raw_document(receipt) .ok_or_else(|| -> anyhow::Error { - PresenceAuthorityRefused("Idunn projects no Expected for Odin's own incarnation") - .into() - })?; - Ok(()) + fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { + self.0.borrow_mut().accept_raw_document(receipt) } } From c81fc38ecc41a2700297e37a4d2c51a274318d37 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 02:07:22 +0200 Subject: [PATCH 10/34] Odin tests: the catalog test expects the watermark record, the lingering test counts from a first pass Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/main.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index a8f5c24..6f6e20b 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -1878,6 +1878,8 @@ mod tests { assert_eq!(admitted, 64, "the default session table is 64 wide"); assert_eq!(odin.server.session_count(), 64, "the table is full"); + // The first timed pass, with the table already full, publishes once. + odin.pass()?; let before = odin.stored_sequence()?; for _ in 0..5 { odin.timers.last_heartbeat = None; @@ -2296,8 +2298,12 @@ mod tests { .collect() })?; let catalog = odin.catalog()?; - assert_eq!(catalog.len(), 1, "{catalog:?}"); - assert_eq!(catalog[0].schema_id, "ghostlight.doc.v1"); + assert_eq!(documents_of(&catalog, "ghostlight.doc.v1"), 1); + assert_eq!( + documents_of(&catalog, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA), + 0, + "the presence whose projection cannot be read is skipped" + ); Ok(()) } From 2f0d1d4bcb44c7a10af77c3e46739f23c64968f3 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 02:14:27 +0200 Subject: [PATCH 11/34] Odin test: the lingering-publisher test admits until 64 are held, then refuses one A peer that times out on a loaded host no longer changes the count, and the six refused peers no longer wait out their deadline while the first sessions idle out. Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/main.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index 6f6e20b..5665fe1 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -1870,12 +1870,24 @@ mod tests { fn self_presence_lands_while_lingering_publishers_hold_every_session() -> Result<()> { let mut odin = activated_odin()?; let target = odin.server.local_addr()?; - let admitted = odin.serve_while(move || { - (0..70) - .filter(|index| lingering_peer(target, PEER_CONNECTION_BASE + index, None).is_ok()) - .count() + // Peers arrive until 64 are admitted, then one more is refused. A peer + // that times out under load is retried as a new one: the property is a + // full table, not that no packet is ever late. Refused peers wait out + // their whole connect deadline, so only one is tried, or the first + // sessions would idle out before the heartbeats are taken. + let (admitted, overflow_admitted) = odin.serve_while(move || { + let (mut admitted, mut next) = (0, 0); + while admitted < 64 && next < 96 { + if lingering_peer(target, PEER_CONNECTION_BASE + next, None).is_ok() { + admitted += 1; + } + next += 1; + } + let overflow = lingering_peer(target, PEER_CONNECTION_BASE + next, None).is_ok(); + (admitted, overflow) })?; assert_eq!(admitted, 64, "the default session table is 64 wide"); + assert!(!overflow_admitted, "the 65th publisher is refused"); assert_eq!(odin.server.session_count(), 64, "the table is full"); // The first timed pass, with the table already full, publishes once. From 64e64df3bdce07db306fe68e287be16b6e92d88f Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 03:57:39 +0200 Subject: [PATCH 12/34] Odin test: a refresh before the anchor's binding still records the time disagreement Removing the incoming-claim condition from the clock exemption in reconcile survived: only the admission side was pinned. A refresh has no claim to refuse, so with the clock before the anchor's binding it must still write the correlation carrying the provider-trust-anchor-time disagreement. Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/lib.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/odin-daemon/src/lib.rs b/crates/odin-daemon/src/lib.rs index afdb89e..03867d9 100644 --- a/crates/odin-daemon/src/lib.rs +++ b/crates/odin-daemon/src/lib.rs @@ -2637,6 +2637,31 @@ mod tests { Ok(()) } + /// The clock exemption belongs to a claim being admitted. A refresh has no + /// claim to refuse: with the clock before the binding it still writes the + /// incarnation's correlation, carrying the time disagreement, and neither + /// fails nor is refused. + #[test] + fn a_refresh_before_the_anchors_binding_records_the_time_disagreement() -> Result<()> { + let world = TestWorld::new()?; + let service = world.service("ghostlight", Vec::new(), false)?; + let signed = world + .engine(NOW - 200) + .refresh(&service.incarnation()?)? + .expect("Idunn projects the incarnation"); + let correlation = decode_signed(&signed)?; + assert!( + correlation + .disagreements + .iter() + .any(|disagreement| disagreement.code == PROVIDER_ANCHOR_TIME_CODE), + "{:?}", + correlation.disagreements + ); + assert!(!correlation.ready); + Ok(()) + } + fn snapshot_with_stored_presence( service: &TestService, stored: AdmittedPresence, From c0735f17aa641ca59588adbbfbb9d414acc8d43b Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 03:57:40 +0200 Subject: [PATCH 13/34] Odin: providers write only their own namespaces, own-projection I/O errors are retried, the lingering test no longer races the idle timeout Peer writes (Soul P-B, confirmed): persist_generic_document blocked six authority types, so a peer could put an odin.topology_publisher_watermark keyed odin holding the largest sequence; every heartbeat then failed to reserve its sequence, and the record survived restarts. Generic puts are now an allow-list of provider namespaces (PEER_DOCUMENT_NAMESPACES). Odin's own records and Idunn's projected authority live in namespaces no provider can name, so they are refused by construction. The same predicate decides what the catalog serves back, replacing the correlation special case: the watermark, and any future Odin-internal type, is no longer public. Own projection: a plain I/O error reading Odin's own projection (permission denied, not found, interrupted) or a missing file is survived and retried; an error the reader decoded or validated and did not accept stays fatal (PresenceAuthorityRefused). Log flood: survive() now logs through the RuntimeState LogGate, once per subject per minute, instead of printing every pass. Flake: self_presence_lands_while_lingering_publishers_hold_every_session failed on Yggdrasil because 64 peers connect over about 9 s and the fsync-bound passes that follow took 4 to 20 s under disk pressure, so the earliest sessions passed the server's default 30 s idle timeout and expired before the final session_count assertion (44 of 64, measured). No peer ever failed to connect. The test's world now serves with a one hour idle timeout, since the property is a full table at heartbeat time, and it logs any peer that is not admitted. Tests: provider puts of every Odin-owned kind are refused and presence keeps advancing; the catalog serves only presence and provider documents; namespaces match whole segments; I/O versus decode of the own projection, and a missing projection file through the serving loop; repeated failures are counted, not printed again. Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/main.rs | 393 ++++++++++++++++++++++++++++----- 1 file changed, 334 insertions(+), 59 deletions(-) diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index 5665fe1..810e909 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -426,24 +426,32 @@ impl RuntimeState { self.admit_presence_document(&document, unix_millis()?) } - /// Idunn's projection of this process's own incarnation. Every failure to - /// establish it -- unreadable, undecodable, ambiguous, substituted, or - /// absent -- is a failure of Odin's own authority, so it is marked for the - /// temporary fatal rule in `survive`. Failures about other incarnations are - /// not: they are read, logged and skipped where they are met. + /// Idunn's projection of this process's own incarnation. A failure to + /// establish it that is not plain I/O -- undecodable, ambiguous, + /// substituted, or with no Expected for this incarnation -- is a failure of + /// Odin's own authority, so it is marked for the temporary fatal rule in + /// `survive`. A plain I/O error is not (see `own_projection_failure`), nor + /// are failures about other incarnations: they are read, logged and skipped + /// where they are met. fn require_own_projection(&self) -> Result<()> { - CultCacheIdunnProjectionSource::new(&self.options.idunn_projection) + let path = &self.options.idunn_projection; + match CultCacheIdunnProjectionSource::new(path) .projection(&self_incarnation(&self.authority_material)) - .map_err(|error| { - error.context(PresenceAuthorityRefused( - "Odin's own projected authority cannot be read", - )) - })? - .ok_or_else(|| -> anyhow::Error { - PresenceAuthorityRefused("Idunn projects no Expected for Odin's own incarnation") - .into() - })?; - Ok(()) + { + Err(error) => Err(own_projection_failure(error)), + // No file is an I/O condition (Idunn replaces it atomically, so it + // is briefly or wrongly unreadable), not a projection that + // disowns Odin. + Ok(None) if !path.is_file() => Err(anyhow::Error::new(std::io::Error::from( + std::io::ErrorKind::NotFound, + )) + .context(format!("Idunn's projection {} is absent", path.display()))), + Ok(None) => Err(PresenceAuthorityRefused( + "Idunn projects no Expected for Odin's own incarnation", + ) + .into()), + Ok(Some(_)) => Ok(()), + } } fn require_current_write_lease(&self) -> Result<()> { @@ -543,20 +551,19 @@ impl RuntimeState { let Some(schema_id) = envelope.schema_id.clone() else { return Ok(None); }; - if envelope.r#type == OdinRuntimeTopologyCorrelationRecord::TYPE { - return Ok(None); - } if envelope.r#type != GameCultRuntimePresenceHealthRecord::TYPE { - return Ok(Some(CultNetRawDocumentRecord { - schema_id, - record_key: envelope.key.clone(), - stored_at: envelope.stored_at.clone(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: envelope.payload.clone(), - source_runtime_id: None, - source_agent_id: None, - source_role: None, - tags: None, + return Ok(is_peer_document_type(&envelope.r#type).then(|| { + CultNetRawDocumentRecord { + schema_id, + record_key: envelope.key.clone(), + stored_at: envelope.stored_at.clone(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: envelope.payload.clone(), + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + } })); } let presence = decode_presence(&envelope.payload)?; @@ -613,6 +620,22 @@ impl RuntimeState { } } +/// A failure to read Odin's own projection. A plain I/O error (permission +/// denied, not found, interrupted) says nothing about what Idunn published and +/// is retried; anything the reader decoded or validated and did not accept is a +/// failure of Odin's own authority, marked for the fatal rule in `survive`. +fn own_projection_failure(error: anyhow::Error) -> anyhow::Error { + if error + .chain() + .any(|cause| cause.is::()) + { + return error; + } + error.context(PresenceAuthorityRefused( + "Odin's own projected authority cannot be read", + )) +} + /// Once-per-interval admission for repeating log lines, keyed by subject. #[derive(Default)] struct LogGate { @@ -746,17 +769,13 @@ fn serving_pass( ) -> Result { let progressed = poll_server(server, &mut timers.poll_failing_since)?; if is_due(timers.last_projection_refresh, PROJECTION_REFRESH_INTERVAL) { - survive( - "projection refresh", - state.borrow_mut().refresh_all_correlations(), - )?; + let refreshed = state.borrow_mut().refresh_all_correlations(); + survive(&state.borrow(), "projection refresh", refreshed)?; timers.last_projection_refresh = Some(Instant::now()); } if is_due(timers.last_heartbeat, HEARTBEAT_INTERVAL) { - survive( - "self-presence publication", - state.borrow_mut().publish_self_presence("ready"), - )?; + let published = state.borrow_mut().publish_self_presence("ready"); + survive(&state.borrow(), "self-presence publication", published)?; timers.last_heartbeat = Some(Instant::now()); } Ok(progressed) @@ -794,7 +813,7 @@ impl std::error::Error for WriteLeaseLost {} /// is not such a failure (`reconcile`): it is retried. Deleted when Idunn /// proves Odin by its own route challenge /// (Idunn audit cut A3, operator question Q-O5). -fn survive(what: &str, result: Result) -> Result> { +fn survive(state: &RuntimeState, what: &str, result: Result) -> Result> { match result { Ok(value) => Ok(Some(value)), Err(error) @@ -804,7 +823,7 @@ fn survive(what: &str, result: Result) -> Result> { Err(error) } Err(error) => { - eprintln!("Odin {what} failed; retrying on the next pass: {error:#}"); + state.log_repeating(what, format!("failed; retrying on the next pass: {error:#}")); Ok(None) } } @@ -1192,19 +1211,41 @@ fn prior_self_publisher_sequence(path: &Path, signer_identity_id: &str) -> Resul .unwrap_or(0)) } +/// The document namespaces providers publish to the rendezvous. A provider's +/// document is stored and served back only under one of these; Odin's own +/// records (correlations, the publisher watermark, every `odin.*` type) and +/// Idunn's projected authority live in namespaces no provider can name, so a +/// peer cannot write them and the catalog cannot serve them. +const PEER_DOCUMENT_NAMESPACES: &[&str] = &[ + "ghostlight", + "heimdall", + "muninn", + "mimir", + "sleipnir", + "streampixels", + "spotiverse", + "vili", + "weksa", + "repixelizer", + "stonks", + "gamecult.eve", + "gamecult.vili", + "gamecult.loki", +]; + +fn is_peer_document_type(document_type: &str) -> bool { + PEER_DOCUMENT_NAMESPACES.iter().any(|namespace| { + document_type + .strip_prefix(namespace) + .is_some_and(|rest| rest.starts_with('.')) + }) +} + fn persist_generic_document(path: &Path, document: &CultNetRawDocumentRecord) -> Result<()> { let document_type = document_type_for_schema(&document.schema_id)?; ensure!( - !matches!( - document_type.as_str(), - "gamecult.runtime_presence_health" - | "odin.runtime_topology_correlation" - | "idunn.expected_incarnation" - | "idunn.runtime_activation" - | "idunn.process_write_lease" - | "gamecult.service_trust_anchor" - ), - "generic provider traffic cannot write an authority-owned Odin/Idunn document" + is_peer_document_type(&document_type), + "Odin accepts no {document_type} document from a provider" ); let replacement = CultCacheEnvelope { key: document.record_key.clone(), @@ -1547,6 +1588,11 @@ mod tests { } fn activated_odin() -> Result { + activated_odin_serving(CultMeshRudpDocumentServerOptions::default()) + } + + /// The same world with the production server bound under `options`. + fn activated_odin_serving(options: CultMeshRudpDocumentServerOptions) -> Result { let temp = tempfile::tempdir()?; let root = temp.path(); let socket = UdpSocket::bind("127.0.0.1:0")?; @@ -1709,7 +1755,7 @@ mod tests { SinkHandle(state.clone()), SnapshotHandle(state.clone()), CultMeshSystemClock::default(), - CultMeshRudpDocumentServerOptions::default(), + options, )?; Ok(OdinWorld { _temp: temp, @@ -1868,18 +1914,27 @@ mod tests { /// socket, so a self-publication that went over RUDP would find it full. #[test] fn self_presence_lands_while_lingering_publishers_hold_every_session() -> Result<()> { - let mut odin = activated_odin()?; + // Sessions do not idle out in this world. The property is that the + // heartbeat lands while the table is full, and the fsync-bound passes + // it takes can outlast the default 30 s idle timeout on a saturated + // disk (Yggdrasil, 2026-09-30: the first pass alone took 20 s, and + // 20 of the 64 sessions had expired by the last assertion), which + // ends the sessions the test is holding rather than testing anything. + let mut odin = activated_odin_serving(CultMeshRudpDocumentServerOptions { + session_idle_timeout: Duration::from_secs(3600), + ..CultMeshRudpDocumentServerOptions::default() + })?; let target = odin.server.local_addr()?; // Peers arrive until 64 are admitted, then one more is refused. A peer // that times out under load is retried as a new one: the property is a // full table, not that no packet is ever late. Refused peers wait out - // their whole connect deadline, so only one is tried, or the first - // sessions would idle out before the heartbeats are taken. + // their whole connect deadline, so only one is tried. let (admitted, overflow_admitted) = odin.serve_while(move || { let (mut admitted, mut next) = (0, 0); while admitted < 64 && next < 96 { - if lingering_peer(target, PEER_CONNECTION_BASE + next, None).is_ok() { - admitted += 1; + match lingering_peer(target, PEER_CONNECTION_BASE + next, None) { + Ok(()) => admitted += 1, + Err(error) => eprintln!("peer {next} was not admitted: {error:#}"), } next += 1; } @@ -1924,6 +1979,18 @@ mod tests { ); odin.timers = ServingTimers::default(); odin.pass()?; + let suppressed = |odin: &OdinWorld| odin.state.borrow().log_gate.borrow().suppressed_total; + assert_eq!(suppressed(&odin), 0, "each distinct failure is logged"); + + // The same faults on later passes are counted, not printed again. + for repeat in 1..=3 { + odin.timers = ServingTimers::default(); + odin.pass()?; + assert!( + suppressed(&odin) >= repeat, + "pass {repeat} repeated a logged failure" + ); + } std::fs::remove_file(&odin.store)?; odin.timers = ServingTimers::default(); @@ -2483,15 +2550,223 @@ mod tests { Ok(()) } + /// A provider put, as the server hands it to the sink. + fn provider_put( + odin: &OdinWorld, + schema: &str, + key: &str, + payload: Vec, + ) -> Result<()> { + odin.state + .borrow_mut() + .accept_raw_document(CultMeshRudpRawDocumentReceipt { + session: cultmesh_rs::CultMeshRudpSessionKey { + remote_addr: "127.0.0.1:1".parse()?, + connection_id: 7, + }, + message_id: "put".into(), + transport_sequence: 1, + received_at_unix_millis: unix_millis()?, + document: CultNetRawDocumentRecord { + schema_id: schema.into(), + record_key: key.into(), + stored_at: rfc3339_millis(unix_millis()?)?, + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload, + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + }, + }) + } + + /// Soul's probe P-B: a peer that puts a document of Odin's own kind (the + /// publisher watermark, keyed by Odin's target, holding the largest + /// sequence) would freeze every later heartbeat and survive restarts. Odin + /// accepts only the namespaces providers publish in, so every record Odin + /// or Idunn owns is refused, and presence keeps advancing. #[test] - fn only_the_write_lease_is_fatal_in_the_serving_loop() { - assert_eq!(survive("x", Ok(3)).unwrap(), Some(3)); + fn a_peer_cannot_write_a_record_odin_owns_and_presence_keeps_advancing() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + let before = odin.stored_sequence()?; + let stored_before = + SingleFileMessagePackBackingStore::new(&odin.store).pull_all_read_only_snapshot()?; + + // Every kind Odin itself stored (its watermark and correlations, found + // rather than named), and the kinds Idunn projects to it. + let mut owned: BTreeSet<(String, String)> = stored_before + .iter() + .filter(|entry| entry.r#type != GameCultRuntimePresenceHealthRecord::TYPE) + .map(|entry| (entry.r#type.clone(), entry.key.clone())) + .collect(); + for kind in [ + IdunnExpectedIncarnationRecord::TYPE, + IdunnRuntimeActivationRecord::TYPE, + IdunnProcessWriteLeaseRecord::TYPE, + GameCultServiceTrustAnchorRecord::TYPE, + "odin.topology_publisher_watermark", + "odin.interface_layout", + ] { + owned.insert((kind.into(), TARGET.into())); + } + assert!( + owned + .iter() + .any(|(kind, _)| kind == OdinRuntimeTopologyCorrelationRecord::TYPE), + "the store holds Odin's own records: {owned:?}" + ); + for (kind, key) in &owned { + let refused = provider_put( + &odin, + &format!("{kind}.v1"), + key, + rmp_serde::to_vec(&(format!("{kind}.v1"), TARGET, u64::MAX))?, + ) + .expect_err(&format!("{kind} must be refused")); + assert!(format!("{refused:#}").contains("accepts no"), "{refused:#}"); + } assert_eq!( - survive::<()>("x", Err(anyhow::anyhow!("io"))).unwrap(), + SingleFileMessagePackBackingStore::new(&odin.store).pull_all_read_only_snapshot()?, + stored_before, + "no refused put touched the store" + ); + + odin.timers = ServingTimers::default(); + odin.pass()?; + assert_eq!( + odin.stored_sequence()?, + before + 1, + "Odin's own presence still advances" + ); + Ok(()) + } + + /// The same rule read the other way: the catalog serves a provider's + /// document and Odin's presence, and nothing else Odin holds. + #[test] + fn the_catalog_serves_provider_documents_and_presence_only() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + provider_put(&odin, "ghostlight.doc.v1", "doc-1", vec![1])?; + let stored: BTreeSet = + SingleFileMessagePackBackingStore::new(&odin.store) + .pull_all_read_only_snapshot()? + .into_iter() + .map(|entry| entry.r#type) + .collect(); + assert!( + stored.len() >= 4, + "Odin's store holds more than what it serves: {stored:?}" + ); + + let served: BTreeSet = odin + .catalog()? + .into_iter() + .map(|document| document.schema_id) + .collect(); + assert_eq!( + served, + BTreeSet::from([ + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.to_owned(), + "ghostlight.doc.v1".to_owned() + ]) + ); + Ok(()) + } + + #[test] + fn provider_namespaces_match_whole_segments() { + for accepted in ["ghostlight.doc", "heimdall.command_boundary", "gamecult.eve.command"] { + assert!(is_peer_document_type(accepted), "{accepted}"); + } + for refused in [ + "ghostlightx.doc", + "ghostlight", + "gamecult.eve", + "gamecult.runtime_presence_health", + "gamecult.service_trust_anchor", + "odin.topology_publisher_watermark", + "idunn.process_write_lease", + ] { + assert!(!is_peer_document_type(refused), "{refused}"); + } + } + + #[test] + fn only_the_write_lease_is_fatal_in_the_serving_loop() -> Result<()> { + let odin = activated_odin()?; + let state = odin.state.borrow(); + assert_eq!(survive(&state, "x", Ok(3))?, Some(3)); + assert_eq!( + survive::<()>(&state, "x", Err(anyhow::anyhow!("io")))?, None ); let lost = anyhow::Error::new(WriteLeaseLost("gone".into())).context("refreshing"); - assert!(survive::<()>("x", Err(lost)).is_err()); + assert!(survive::<()>(&state, "x", Err(lost)).is_err()); + Ok(()) + } + + /// A plain I/O error reading Odin's own projection (permission denied, not + /// found, interrupted) says nothing about what Idunn published: it is + /// retried. A record the reader could not decode or validate is Odin's own + /// authority failing, and stays fatal. + #[test] + fn a_plain_io_error_reading_its_own_projection_is_retried_a_decode_failure_is_not() -> Result<()> + { + let odin = activated_odin()?; + let state = odin.state.borrow(); + for kind in [ + std::io::ErrorKind::PermissionDenied, + std::io::ErrorKind::NotFound, + std::io::ErrorKind::Interrupted, + ] { + let read = anyhow::Error::new(std::io::Error::from(kind)).context("failed to read"); + assert_eq!( + survive::<()>(&state, "projection", Err(own_projection_failure(read)))?, + None, + "{kind:?}" + ); + } + let undecodable = anyhow::anyhow!("failed to decode MessagePack: invalid marker"); + assert_refused( + &survive::<()>(&state, "projection", Err(own_projection_failure(undecodable))) + .unwrap_err(), + ); + Ok(()) + } + + /// The same rule through the serving loop: with Idunn's projection file + /// gone Odin keeps serving and publishes again once it is back; with a + /// projection that does not decode it ends. + #[test] + fn a_missing_projection_file_is_retried_by_the_serving_loop() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + let before = odin.stored_sequence()?; + let projection = odin.state.borrow().options.idunn_projection.clone(); + let bytes = std::fs::read(&projection)?; + + std::fs::remove_file(&projection)?; + for _ in 0..3 { + odin.timers = ServingTimers::default(); + odin.pass()?; + } + assert_eq!(odin.stored_sequence()?, before, "nothing is published blind"); + + std::fs::write(&projection, bytes)?; + odin.timers = ServingTimers::default(); + odin.pass()?; + assert!( + odin.stored_sequence()? > before, + "publication resumes once the projection is readable" + ); + + std::fs::write(&projection, b"not a cultcache store")?; + odin.timers = ServingTimers::default(); + assert_refused(&odin.pass().unwrap_err()); + Ok(()) } #[test] From 5dd5b4a8630362e122becec18addcfb01ac407f9 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 05:19:46 +0200 Subject: [PATCH 14/34] Odin: peer namespaces are the ones a source publishes, and only a failed file read is retried The allow-list drops nine guessed namespaces and gains gamecult.model, gamecult.aetheria and the two gamecult.media_stream_* types (they are not under a dotted gamecult.media segment, so they are whole types), each with its publisher named. The own-projection failure rule now retries only what the file read itself marked, so a truncated Expected record ends Odin. Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/lib.rs | 30 +++++- crates/odin-daemon/src/main.rs | 182 +++++++++++++++++++++++++++------ 2 files changed, 180 insertions(+), 32 deletions(-) diff --git a/crates/odin-daemon/src/lib.rs b/crates/odin-daemon/src/lib.rs index 03867d9..7c2f115 100644 --- a/crates/odin-daemon/src/lib.rs +++ b/crates/odin-daemon/src/lib.rs @@ -250,12 +250,36 @@ impl CultCacheIdunnProjectionSource { if !self.path.is_file() { return Ok(None); } - Ok(Some( - SingleFileMessagePackBackingStore::new(&self.path).pull_all_read_only_snapshot()?, - )) + SingleFileMessagePackBackingStore::new(&self.path) + .pull_all_read_only_snapshot() + .map(Some) + .map_err(|error| { + // The store's decode step reports its failures as text, so an + // I/O error in the chain here is from opening or reading the + // file and from nothing the file said. + if error.chain().any(|cause| cause.is::()) { + error.context(ProjectionUnreadable) + } else { + error + } + }) } } +/// The projection file could not be opened or read. Marks the read step only: +/// a failure while decoding or validating what was read never carries it, +/// whatever it wraps. +#[derive(Debug)] +pub struct ProjectionUnreadable; + +impl std::fmt::Display for ProjectionUnreadable { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Idunn's projection file could not be read") + } +} + +impl std::error::Error for ProjectionUnreadable {} + fn provider_anchor( entries: &[CultCacheEnvelope], target: &str, diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index 810e909..d900df8 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -36,7 +36,7 @@ use fs2::FileExt; use odin_daemon::{ AuthenticationPolicy, CultCacheIdunnProjectionSource, CultCacheOdinTopologyStore, IdunnProjectionSource, IncarnationRef, OdinTopologyAuthority, PresenceAuthorityRefused, - SystemClock, + ProjectionUnreadable, SystemClock, }; const TARGET: &str = "odin"; @@ -427,10 +427,10 @@ impl RuntimeState { } /// Idunn's projection of this process's own incarnation. A failure to - /// establish it that is not plain I/O -- undecodable, ambiguous, + /// establish it that is not a failed file read -- undecodable, ambiguous, /// substituted, or with no Expected for this incarnation -- is a failure of /// Odin's own authority, so it is marked for the temporary fatal rule in - /// `survive`. A plain I/O error is not (see `own_projection_failure`), nor + /// `survive`. A failed file read is not (see `own_projection_failure`), nor /// are failures about other incarnations: they are read, logged and skipped /// where they are met. fn require_own_projection(&self) -> Result<()> { @@ -620,14 +620,16 @@ impl RuntimeState { } } -/// A failure to read Odin's own projection. A plain I/O error (permission +/// A failure to read Odin's own projection. Only a failure of the file read +/// itself (marked `ProjectionUnreadable` where the file is read: permission /// denied, not found, interrupted) says nothing about what Idunn published and -/// is retried; anything the reader decoded or validated and did not accept is a -/// failure of Odin's own authority, marked for the fatal rule in `survive`. +/// is retried. Anything the reader decoded or validated and did not accept is a +/// failure of Odin's own authority, marked for the fatal rule in `survive`, +/// whatever error it wraps. fn own_projection_failure(error: anyhow::Error) -> anyhow::Error { if error .chain() - .any(|cause| cause.is::()) + .any(|cause| cause.is::()) { return error; } @@ -1216,29 +1218,52 @@ fn prior_self_publisher_sequence(path: &Path, signer_identity_id: &str) -> Resul /// records (correlations, the publisher watermark, every `odin.*` type) and /// Idunn's projected authority live in namespaces no provider can name, so a /// peer cannot write them and the catalog cannot serve them. +/// +/// The list is evidence, not a guess: each entry is a namespace some GameCult +/// source publishes to Odin's catalog connection, and the publisher is named +/// here so the next edit can check it. Add a namespace only with its publisher. +/// ghostlight Ghostlight, `ghostlight.schema_catalog.v1` +/// (schema: CultLib `packages/cultcache-ts/src/swarm-documents.ts`) +/// heimdall Heimdall `src/odin-publication.ts` via `src/verse-state.ts`, +/// `heimdall.command_boundary.v1` +/// muninn Muninn `crates/muninn-contracts/src/records.rs`, +/// `muninn.obs_stream_catalog.v1` +/// sleipnir Sleipnir, `sleipnir.input_mapping.v1` +/// (schema: CultLib `packages/cultcache-ts/src/swarm-documents.ts`) +/// gamecult.eve Eve providers (Heimdall `src/odin-publication.ts`, Muninn, +/// AetheriaEve): `provider_advertisement`, `surface`, +/// `surface_state`, `plugin_advertisement` +/// gamecult.model Epiphany `epiphany-core/src/atlas/transport.rs`, +/// `gamecult.model.atlas_publication.v0` +/// gamecult.aetheria AetheriaEve `Aetheria.State.Daemon/Program.cs`, +/// `gamecult.aetheria.asset_manifest.v1` const PEER_DOCUMENT_NAMESPACES: &[&str] = &[ "ghostlight", "heimdall", "muninn", - "mimir", "sleipnir", - "streampixels", - "spotiverse", - "vili", - "weksa", - "repixelizer", - "stonks", "gamecult.eve", - "gamecult.vili", - "gamecult.loki", + "gamecult.model", + "gamecult.aetheria", +]; + +/// Whole document types published under no dotted namespace: Muninn's media +/// stream advertisement and Ratatoskr/Muninn's request for it (`gamecult.media` +/// is their prefix, `_stream_advertisement` and `_stream_request` are not a +/// segment of it). Publisher: Muninn `crates/muninn-daemon/src/main.rs` +/// (`GAMECULT_MEDIA_STREAM_ADVERTISEMENT_SCHEMA`, `GAMECULT_MEDIA_STREAM_REQUEST_SCHEMA`). +const PEER_DOCUMENT_TYPES: &[&str] = &[ + "gamecult.media_stream_advertisement", + "gamecult.media_stream_request", ]; fn is_peer_document_type(document_type: &str) -> bool { - PEER_DOCUMENT_NAMESPACES.iter().any(|namespace| { - document_type - .strip_prefix(namespace) - .is_some_and(|rest| rest.starts_with('.')) - }) + PEER_DOCUMENT_TYPES.contains(&document_type) + || PEER_DOCUMENT_NAMESPACES.iter().any(|namespace| { + document_type + .strip_prefix(namespace) + .is_some_and(|rest| rest.starts_with('.')) + }) } fn persist_generic_document(path: &Path, document: &CultNetRawDocumentRecord) -> Result<()> { @@ -2678,7 +2703,7 @@ mod tests { #[test] fn provider_namespaces_match_whole_segments() { - for accepted in ["ghostlight.doc", "heimdall.command_boundary", "gamecult.eve.command"] { + for accepted in ["ghostlight.doc", "heimdall.command_boundary", "gamecult.eve.command", "gamecult.media_stream_request"] { assert!(is_peer_document_type(accepted), "{accepted}"); } for refused in [ @@ -2694,6 +2719,62 @@ mod tests { } } + /// One real schema id per namespace, each taken from its publisher (see + /// `PEER_DOCUMENT_NAMESPACES`): Odin accepts the put and the catalog serves + /// it. Soul's probe P1-A is the media pair, which Muninn and Ratatoskr + /// exchange through Odin. + #[test] + fn every_publisher_schema_is_accepted_and_served() -> Result<()> { + const PUBLISHED: &[&str] = &[ + "ghostlight.schema_catalog.v1", + "heimdall.command_boundary.v1", + "muninn.obs_stream_catalog.v1", + "sleipnir.input_mapping.v1", + "gamecult.eve.provider_advertisement.v1", + "gamecult.eve.surface.v1", + "gamecult.eve.surface_state.v1", + "gamecult.eve.plugin_advertisement.v1", + "gamecult.model.atlas_publication.v0", + "gamecult.aetheria.asset_manifest.v1", + "gamecult.media_stream_advertisement.v1", + "gamecult.media_stream_request.v1", + ]; + let odin = activated_odin()?; + for schema in PUBLISHED { + provider_put(&odin, schema, "key", vec![1])?; + } + let served: BTreeSet = odin + .catalog()? + .into_iter() + .map(|document| document.schema_id) + .collect(); + for schema in PUBLISHED { + assert!(served.contains(*schema), "{schema} is not served"); + } + Ok(()) + } + + /// Namespaces no source publishes to Odin are refused, and a lookalike of + /// the media types is not the media types. + #[test] + fn unpublished_namespaces_are_refused() -> Result<()> { + let odin = activated_odin()?; + for schema in [ + "mimir.doc.v1", + "streampixels.doc.v1", + "vili.doc.v1", + "gamecult.loki.doc.v1", + "gamecult.media.doc.v1", + "gamecult.media_stream_advertisement_x.v1", + ] { + assert!( + provider_put(&odin, schema, "key", vec![1]).is_err(), + "{schema}" + ); + } + Ok(()) + } + #[test] fn only_the_write_lease_is_fatal_in_the_serving_loop() -> Result<()> { let odin = activated_odin()?; @@ -2708,13 +2789,12 @@ mod tests { Ok(()) } - /// A plain I/O error reading Odin's own projection (permission denied, not - /// found, interrupted) says nothing about what Idunn published: it is - /// retried. A record the reader could not decode or validate is Odin's own - /// authority failing, and stays fatal. + /// A failure of the projection file read (permission denied, not found, + /// interrupted) says nothing about what Idunn published: it is retried. A + /// record the reader could not decode or validate is Odin's own authority + /// failing, and stays fatal even when the decoder wraps an I/O error. #[test] - fn a_plain_io_error_reading_its_own_projection_is_retried_a_decode_failure_is_not() -> Result<()> - { + fn a_projection_read_failure_is_retried_a_decode_failure_is_not() -> Result<()> { let odin = activated_odin()?; let state = odin.state.borrow(); for kind in [ @@ -2722,7 +2802,9 @@ mod tests { std::io::ErrorKind::NotFound, std::io::ErrorKind::Interrupted, ] { - let read = anyhow::Error::new(std::io::Error::from(kind)).context("failed to read"); + let read = anyhow::Error::new(std::io::Error::from(kind)) + .context("failed to read") + .context(ProjectionUnreadable); assert_eq!( survive::<()>(&state, "projection", Err(own_projection_failure(read)))?, None, @@ -2734,6 +2816,48 @@ mod tests { &survive::<()>(&state, "projection", Err(own_projection_failure(undecodable))) .unwrap_err(), ); + // A decoder that wraps an I/O error (MessagePack does, for a record + // that ends early) is still a decode failure. + let wrapped = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)) + .context("failed to decode Expected"); + assert_refused( + &survive::<()>(&state, "projection", Err(own_projection_failure(wrapped))) + .unwrap_err(), + ); + Ok(()) + } + + /// Odin's own Expected record cut short by one byte is undecodable, so + /// Odin ends, though the decoder's error wraps an unexpected end of file. + #[test] + fn a_truncated_own_expected_record_ends_odin() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + odin.tamper_projection(|mut entries| { + for entry in &mut entries { + if entry.r#type == IdunnExpectedIncarnationRecord::TYPE { + entry.payload.pop(); + } + } + entries + })?; + odin.timers = ServingTimers::default(); + assert_refused(&odin.pass().unwrap_err()); + Ok(()) + } + + /// A projection store cut short on disk is a decode failure of the file's + /// content, not a read failure of the file. + #[test] + fn a_truncated_projection_store_ends_odin() -> Result<()> { + let mut odin = activated_odin()?; + odin.pass()?; + let projection = odin.state.borrow().options.idunn_projection.clone(); + let mut bytes = std::fs::read(&projection)?; + bytes.pop(); + std::fs::write(&projection, bytes)?; + odin.timers = ServingTimers::default(); + assert_refused(&odin.pass().unwrap_err()); Ok(()) } From dc46be205a604d8bca422caf0a40c752907f5689 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 05:23:54 +0200 Subject: [PATCH 15/34] Odin: the projection-unreadable mark is found through anyhow context layers Co-Authored-By: Claude Sonnet 5.5 --- crates/odin-daemon/src/main.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/odin-daemon/src/main.rs b/crates/odin-daemon/src/main.rs index d900df8..d7d5c39 100644 --- a/crates/odin-daemon/src/main.rs +++ b/crates/odin-daemon/src/main.rs @@ -627,10 +627,7 @@ impl RuntimeState { /// failure of Odin's own authority, marked for the fatal rule in `survive`, /// whatever error it wraps. fn own_projection_failure(error: anyhow::Error) -> anyhow::Error { - if error - .chain() - .any(|cause| cause.is::()) - { + if error.downcast_ref::().is_some() { return error; } error.context(PresenceAuthorityRefused( From 4b525b463cecacd598f009072036385a4dc49657 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 12:39:32 +0200 Subject: [PATCH 16/34] Map Odin's write pattern: memory working set, one write per interval Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 383 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 docs/write-pattern-cut.md diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md new file mode 100644 index 0000000..e174014 --- /dev/null +++ b/docs/write-pattern-cut.md @@ -0,0 +1,383 @@ +# Odin write pattern: cut map + +**2026-09-30, Self.** Operator: "Let's fix Odin up, that's some furious bookkeeping right there. Look at Odin's +write pattern and see if append only or per-document is the way to go." Answer: neither is the first fix. Cuts 1-2 +go to Hands now. Q3 (a 1 s interval) and Q4 (the Rust directory store as a CultLib follow-up) are Self defaults, +and the operator may overrule them. Q1 and Q2 are put to the operator, and Cut 3 waits on Q1. + +Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. Anchors are Odin `main` at +`44951a1` (the deployed build, live on Yggdrasil since 2026-09-30 04:20:48 UTC) and CultLib `3bf1c0ce` +(Odin's `Cargo.lock` pin). This map owns the means; there is no separate target document, because the ends are +three sentences (section 2). + +Operator's words, 2026-09-30: "Let's fix Odin up, that's some furious bookkeeping right there. Look at Odin's +write pattern and see if append only or per-document is the way to go." + +**Short answer.** Neither is the first move. Odin's IO comes from treating its store file as working memory and +from carrying two unservable 1.5 MB Eve documents inside it, not from the file format. Three cuts remove about +99% of the bytes read and written, and bring the rewrites per second from 5 down to at most 1. None of them +changes the on-disk format. For Odin's data, per-document storage is the better fit than append-only, but after +those cuts it would save about 90 KB/s and no fsyncs. It does not earn a place on Odin's critical path +(section 5). + +Open: Q1 to Q4 (section 7). Cut 1 needs no ruling. Cut 2 needs Q3. Cut 3 needs Q1 and Q2. + +## 1. Body facts (measured) + +### 1.1 Live Odin, `/proc/375952/io` and `ss`, read-only, 2026-09-30 ~10:14 UTC + +| Quantity | Process lifetime (21,252 s) | 60 s sample (10:15-10:16 UTC) | +|---|---|---| +| logical read (`rchar`) | 1.80 TB, 84.8 MB/s | 6.37 GB, **106 MB/s** | +| written (`wchar`) | 276.5 GB, 13.0 MB/s | 972 MB, **16.2 MB/s** | +| written to disk (`write_bytes`) | 276.8 GB (all of it) | 16.2 MB/s | +| read from disk (`read_bytes`) | **0** | 0 | +| write syscalls (`syscw`) | 85,420, 4.0/s | 300, **5.0/s** | +| bytes per write syscall | 3.24 MB | 3.24 MB | +| CPU | 1,529 s, 7.2% of one core | | +| socket drops (`ss -m`, `d`) | **136,291** | 0 in one 30 s sample (drops come in bursts) | + +- The store is `/var/lib/gamecult/odin/topology.cc`: **3,240,078 bytes** (`ls -l`; the contents were not read). + Every write syscall is one whole-store rewrite: 5.0 per second, each followed by a file `fsync` and a + directory `fsync`. +- `read_bytes = 0`. The "376 GB read" in the audit is page-cache reads. It costs CPU and memory bandwidth + (repeated MessagePack decodes of 3.24 MB), not disk. **The disk cost is the writes: 16 MB/s and about 10 + fsyncs per second.** +- The audit's figures (376 GB read, 63 GB written in 4 h, `MORNING-2026-09-30.md` line 214) came from the + previous process (`c34d167`). This process runs at about 3.7 times those rates, with the same 6.5:1 read/write + shape. The cause is the same. +- The idle Odin socket drops datagrams in bursts. Its 213 KB "backlog" in the audit is the receive buffer size + (`rb212992`); the drop counter (136,291) is the real signal. +- Idunn currently reads 211 KB in 30 s. Its `--odin-correlation-store` read of the same file is not a + significant load. + +### 1.2 Cost of one operation on Yggdrasil's disk (scratch probe) + +Probe: `scratchpad/odin-write-probe` (Imagination session), commit `71b376a6`. It was run through the stopgap +in slot 5, on `/dev/vda4` (the host disk) with `TMPDIR` on that disk, at CultLib `3bf1c0ce`. Bytes are +`/proc/self/io` deltas and times are wall clock under the live IO pressure (`/proc/pressure/io` full avg60 +about 10-12%). The store shapes use the payload sizes the live catalog reports. +TODAY adds two 1.55 MB records; SUBTRACTED omits them. + +| Operation | TODAY (3.24 MB store) | SUBTRACTED (131 KB) | +|---|---|---| +| full read and decode | 3.24 MB, 0.78 ms | 131 KB, 0.14 ms | +| one presence heartbeat as coded today (read, reserve watermark CAS, correlation CAS) | 16.2 MB read, **6.49 MB written, 54.8 ms** | 663 KB, 265 KB, 14.4 ms | +| the same heartbeat folded into one CAS | 6.49 MB read, 3.24 MB written, 36.5 ms | 268 KB, **134 KB, 7.1 ms** | +| redb (cultcache-rs `RedbMessagePackBackingStore`), one small-row CAS | 33 KB, 42.6 KB, 5.4 ms | 33 KB, 42.6 KB, 8.7 ms | + +Odin's serving loop is single-threaded (`main.rs:739-747`), so every millisecond in the table is a +millisecond in which no datagram is read. Five writes per second at 36-55 ms each block the loop for 180-275 +ms of every second on a quiet disk, and for longer under pressure. That is the socket drops and the route +challenge timeouts. + +### 1.3 What gets written, and why (source, `44951a1`) + +Every mutation reads the whole file, then CAS-rewrites the whole file, through CultLib's +`SingleFileMessagePackBackingStore` (`cultcache-rs/src/lib.rs` at `3bf1c0ce`: `compare_exchange` reads all +entries under an exclusive lock, rewrites all of them, then `fsync`s the file and its directory). + +| Trigger | Rate (measured from catalog `stored_at` churn over 88 s, and from the publisher's source) | Store ops per event | +|---|---|---| +| Muninn republishes `muninn.obs_stream_catalog`, `muninn.move_hue_program` and `gamecult.media_stream_advertisement` every 2 s (Muninn `main.rs:101`, `:2241`). The payload changes each time: `updated_at` (`:3263`) | about 1.5/s | `persist_generic_document` `main.rs:1266-1309`: 1 read, then 1 CAS (read and write) | +| Provider presence heartbeats: raven-muninn about every 2.7 s, Odin 5 s, streampixels-service about 10 s, ghostlight about 12 s | about 0.8/s | `admit_presence`, then `reconcile` `lib.rs:771-977`: `store.read` (1 read), `reserve_publisher_sequence` `lib.rs:613-647` (read and **write**), `compare_exchange` `lib.rs:525-584` (read and **write**) | +| Odin's own heartbeat (`HEARTBEAT_INTERVAL` 5 s, `main.rs:71`) | 0.2/s | the same path, through `publish_self_presence` `main.rs:422-427` | +| Provider ads and other documents restamped (voidbot.swarm about every 30 s, and others) | about 0.1/s | `persist_generic_document` | +| Projection refresh every 250 ms (`main.rs:72`, `:770-774`) | 4/s | `incarnation_keys(store)` `main.rs:486-489` (1 read), then per incarnation `refresh` (projection read, then `store.read`). A write only when facts change | +| Catalog queries (Hermodr, Idunn, probes) | on request | `stored_snapshot` `main.rs:509-543` (1 full read, then a projection read per presence) | + +- A correlation embeds the observed presence sequence and hash (`lib.rs:887-895`), so + `same_correlation_facts` (`lib.rs:1273-1282`) never dedupes a heartbeat. **Every heartbeat costs two + whole-store writes**: first the watermark, then the correlation. +- About 2.1 of the 5.0 writes per second are accounted for by Muninn and the other catalog documents, and about + 2.0 by heartbeats. The remaining 0.9 per second are refresh-driven correlation changes (dependency freshness + windows, O2 in the Idunn audit) plus writes that were not attributed. The two 1.5 MB records are not among the + catalog churn seen through the probe; their publisher and rate are unknown (Q2). +- Reads: about 30 whole-store decodes per second. Most are the 250 ms refresh (1 + N incarnations per pass), + and the rest are admission reads. + +### 1.4 What is in the store: truth, cache or projection? + +| Kind | Who owns the truth | Odin's role | Who reads Odin's copy | Must it be durable? | +|---|---|---|---|---| +| Provider catalog documents (ads, boundaries, profiles, media ads, schema catalog, surface_state) | the provider | relay cache | the Verse, via the catalog | yes, but not per write. Some publishers put once and never again (for example `heimdall.command_boundary`, whose `stored_at` is from 09-23), so losing the catalog on a restart would lose them. The CultMesh put has no application acknowledgement (`cultmesh-rs rudp_document_server.rs:464-482`), so a put promises the publisher no durability | +| `gamecult.eve.surface` and `surface_state`, 1.55 and 1.56 MB | unknown provider (Q2) | a cache **that can never be served**: a single-record snapshot is over the 1 MiB limit (Odin journal: "snapshot response is 1547739 bytes; limit is 1048576", and 1563575) | nobody can | no. They are 93% of the store's bytes and ride along in every rewrite | +| Runtime presences | the provider (signed) | admitted evidence with anti-replay (O5, O6) | the Verse; Odin's own sequence continuation (`main.rs:1184-1211`) | the latest per incarnation. The 30 s observation window (O6) bounds any replay after a lost write | +| Correlations and the publisher watermark | Odin | a **projection** of presence plus Idunn's projection | Idunn, lockless, from this same file (`--odin-correlation-store /var/lib/gamecult/odin/topology.cc`; Idunn `drivers.rs:5005-5030`) | the watermark must never let a *published* sequence repeat. "Published" means written to this file, because since batch 2 nothing else serves correlations. Both die at Idunn A3 and Odin O-R (`Idunn/docs/route-continuity-cut.md` section 4) | +| Presence history of withdrawn incarnations | nobody | retained on purpose (`withdrawn_expected_removes_current_correlation_but_preserves_presence_history`, `lib.rs:2814`) | only Odin's own sequence continuation reads it, and only Odin's own key | grows without bound with every deploy (follow-up F4) | + +## 2. Ends + +1. Odin reads its store once, at activation, and never again while it holds the write lease. Its working + state is in memory. +2. Odin writes its store at most once per flush interval, only when the content changed, as one atomic write + under the lock. A foreign write to the file is detected and ends Odin. +3. The store holds nothing Odin cannot serve. + +Invariants that must survive: +- One writer: the process-write lease (`main.rs:457-474`) is the only authority to write. Nothing writes after + the lease is lost. +- Idunn's crossing: until A3, Idunn reads correlations from `topology.cc` with + `pull_all_read_only_snapshot`. The path, the single-file `.cc` format and the atomic replace do not change. +- Correlation sequences never repeat, as published. +- One bad record never stops Odin. This now holds at load: records are skipped and logged, never fatal + (`dc46be2`). +- Presence anti-replay within an activation (O5). + +## 3. Identity, lifecycle, authority + +| Persistent kind | Named by | Over time | Decided by | +|---|---|---|---| +| catalog document | `(type, key)`, type from the schema id (`main.rs:1311-1326`) | replaced by each provider put; never deleted | the provider decides the content; Odin decides admission (allow-list `main.rs:1237-1264`, plus the size rule in Cut 3) | +| presence | `presence_store_key(incarnation, signer)` `lib.rs:1528` | replaced each heartbeat; kept after withdrawal (F4) | provider bytes, Odin's admission | +| correlation | incarnation key | replaced when facts change; deleted on withdrawal (`lib.rs:586-611`) | Odin; dies at O-R | +| watermark | target | only ever increases | Odin; dies at O-R | +| in-memory working set (new) | the same keys | loaded at activation, mutated per event, never reloaded while serving | Odin under the lease | +| flush (new) | none: one atomic snapshot | at most one per interval when dirty; one final flush on stop while the lease is held | Odin's serving loop | + +## 4. Cuts + +Subtraction cuts come before behaviour cuts. Each cut is its own branch and Soul pass. All verification runs on +Yggdrasil through the stopgap (`cargo test --locked -p odin-daemon` from the Odin root). + +### Cut 1. The file stops being working memory (Odin, subtraction) + +- **Repo/branch:** Odin `hands/odin-memory-store` from `44951a1`. Depends on nothing. +- **First:** a 60 s `/proc//io` sample of live Odin, plus the `ss -m` drop counter (section 6 script). + The numbers in 1.1 serve as the baseline if they are taken within a day. +- **Deletes first:** + - `lib.rs:432-648` `CultCacheOdinTopologyStore` (about 215 lines): every `pull_all_read_only_snapshot` and + CAS against the store file, and its four `CAS_ATTEMPTS` retry loops (`:453`, `:588`, `:614`, and the one + in `reconcile` at `:799`). With one writer under the lease, an in-memory compare-exchange cannot lose, so + the retry loops protect nothing. + - `main.rs:1266-1309` `persist_generic_document`'s file CAS loop. + - `main.rs:486-489`: the store half of `incarnation_keys` (the projection half stays). + - `main.rs:513-518`: `stored_snapshot`'s file read. + - `main.rs:1184-1211` `prior_self_publisher_sequence`'s file read (it becomes a query of the loaded set). +- **Keeps:** the `OdinTopologyStore` trait (`lib.rs:410-430`) as the mock point. `OdinTopologyAuthority` does + not change. +- **Adds:** one in-memory store owned by `RuntimeState`, keyed `(type, key)`, implementing `OdinTopologyStore`. + It is loaded once in `try_activate` (`main.rs:229-267`) after the lease is verified, with the locked + `pull_all`, and replaces the load's current callers. Legacy retirement (`main.rs:736`, + `retire_legacy_correlations` `lib.rs:452-478`) and the self sequence (`main.rs:201-208`) become operations on + the loaded set. Undecodable records are skipped and logged at load, as today. + - The projection is read **once per refresh pass**, not once per incarnation: `refresh_all_correlations` + `main.rs:476-501` builds one projection snapshot and hands it to each `refresh`. Name the type; + `IdunnProjectionSource` is already the port. + - Hands chooses whether the trait's `compare_exchange` stays or collapses into `read` plus `commit`. The rule + is that no path retries a CAS that cannot lose. +- **Not in this cut:** the write policy. At the end of Cut 1 every mutation still writes the whole file + immediately, now from memory: one read per write inside CultLib's compare, and no read otherwise. +- **Authority map:** + - Owner: `RuntimeState`'s in-memory set, under the process-write lease. + - Inputs: one locked read at activation; provider puts; Idunn's projection (read per pass). + - Outputs: the catalog, correlations, and the file (Cut 2 owns when). + - Derived state: the file is a durability checkpoint and Idunn's crossing. It is **no longer an owner** of any + decision while Odin serves. + - Forbidden writers: no second reader-as-authority. No code path in the serving loop may call + `pull_all_read_only_snapshot` or `pull_all` on `options.store`. + - Shared paths: provider presence, self presence, generic puts, refresh and catalog all mutate or read the one + set. + - Deletion line: the `lib.rs` file store and the `main.rs` file reads above are deleted before the memory store + is wired in. +- **Verification:** + - tests: existing behaviour tests keep passing. Tests that corrupt the store file **while Odin serves** + (`one_undecodable_incarnation_leaves_the_others_and_the_catalog_readable` `main.rs:2094`, + `records_that_do_not_decode_never_stop_the_catalog_or_startup` `:2341`) describe a second writer that the + lease forbids. Re-target them to "the bad record is in the file at activation". Do not delete them. + - new test: after activation, rewriting `topology.cc` behind Odin's back changes nothing Odin serves until + restart (pins the rule that the file is not working memory; a mutant that re-reads fails it). + - new test: N refresh passes perform 0 reads of the store file. Observe it where it is decided, with a + counting seam on the load path, or with an `strace`-free `/proc/self/io` delta in the scenario. + - negative: `rg -n "pull_all_read_only_snapshot|pull_all\(" crates/odin-daemon/src/main.rs` matches only the + runtime-bundle, anchor, lease and projection readers (`:1018`, `:1061`, `:1162`, projection), never + `options.store`. + - `cargo mutants --in-diff` on the cut's diff. +- **Estimate:** about -260 / +120 lines. + +### Cut 2. One coalesced write per interval, watermark folded in (Odin, behaviour) + +- **Repo/branch:** Odin `hands/odin-flush` from Cut 1. Needs Q3. +- **Deletes first:** the file half of `reserve_publisher_sequence` (`lib.rs:613-647`). Reserving becomes an + in-memory increment, and the mark lands in the same atomic write as the correlation that uses it. The ordering + comment at `lib.rs:922-926` and the trait doc at `:422-429` are rewritten: "published" means flushed. +- **Adds:** + - `FLUSH_INTERVAL` (1 s, Q3) in `main.rs:71-76`. + - A dirty flag. + - A `flush_if_due` step in `serving_pass` (`main.rs:764-781`). It checks `require_current_write_lease` + immediately before writing, then writes the whole set with + `compare_exchange_snapshot(last_flushed, current)`. A `false` result means the file changed under Odin, which + is a second writer: it is returned as `WriteLeaseLost` (or a sibling type that `survive` treats the same + way). + - One final flush on `SIGTERM`/`SIGINT` (`main.rs:740-743`), only if the lease is still current. +- **Authority map:** + - Owner: the serving loop's flush step decides when the file changes. + - Inputs: the dirty flag, the flush interval, and the current lease. + - Outputs: one atomic `.cc` snapshot. + - Derived: the watermark in the file is at least every sequence in the file. An in-memory sequence that was + never flushed was never published. + - Forbidden writers: `admit_presence`, `persist_generic_document`, `withdraw_correlation` and `reconcile` + never write the file. + - Shared paths: every mutation goes through the one flush. + - Deletion line: the per-mutation writes left by Cut 1 are deleted before the flush is added. +- **Verification:** + - test: 50 heartbeats and puts inside one interval produce exactly 1 file write (pins coalescing). + - test: no mutation produces no write (pins "only when dirty"). + - test: a lease withdrawn between mutation and flush produces no write and ends Odin (pins the one-writer + rule). Observe the file bytes, not a log line. + - test: a crash (drop the state without the stop flush) followed by a reload never republishes a sequence + that is in the file. In other words, the next correlation's sequence is greater than the flushed mark (pins + never-repeat under the new meaning of "published"). + - test: a foreign write between flushes ends Odin without overwriting it. + - Soul must falsify one question: does Idunn fence (stop) an incumbent Odin **before** it writes the + candidate's lease? If not, an incumbent can pass the lease check and flush after the candidate has loaded. + The race exists today as well; this cut must not widen it. + - `cargo mutants --in-diff`; at least one mutant must be a function of the interval, such as a scaled or + clamped interval. +- **Expected live effect:** at most 1 write/s (from 5.0), 2 fsyncs/s (from about 10), about 3.2 MB/s written + while the surfaces remain (from 16.2), and about 3.2 MB/s read (the compare inside the flush). +- **Estimate:** about -40 / +70 lines. + +### Cut 3. Nothing unservable is admitted (CultLib, then Odin) + +Needs Q1 and Q2. **Recommended form (Q1 A):** + +- **Owner:** CultLib `cultmesh-rs`. The document server admits a put of up to 4 MiB per session + (`rudp_document_server.rs:154-156` at `3bf1c0ce`) but can serve at most 1 MiB, so it accepts documents it can + never return. The fix belongs to the server, not Odin. Only `cultmesh-rs` has this server, so there is no + sibling-runtime parity work. +- **CultLib cut:** `hands/cultmesh-put-serve-bound` from CultLib `main`. `deliver_application_message` + `DocumentPutRaw` (`:464-482`) refuses, as an application rejection naming both sizes, any document whose + single-document `SnapshotResponseRaw` would exceed `max_snapshot_response_bytes`. It uses the same encoder + and limit as the snapshot path (`:543`), so the two can never disagree. Test: a put one byte over the bound is + refused and one at the bound is served. +- **Odin cut:** a pin bump, plus one step at activation: stored records that the served-size rule would refuse + are dropped from the loaded set and logged by type, key and size. The next flush removes them from the file. + Test: a store holding an oversize record activates, serves everything else, and the oversize record is gone + after the first flush. +- **Expected live effect:** store about 130 KB, at most 134 KB written per second, at most 7 ms of loop time + per second. +- **Estimate:** CultLib about +25 plus tests; Odin about +20. + +### Cut 4 (not recommended now). Per-document storage + +See section 5. Record it as a CultLib follow-up (F1), not an Odin cut. + +### Subtraction ledger (estimate) + +| Cut | Removed | Added | Formats and targets | +|---|---|---|---| +| 1 | about 260 | about 120 | none | +| 2 | about 40 | about 70 | none | +| 3 | 0 | about 45 (CultLib 25, Odin 20) | none | + +## 5. Append-only against per-document, answered + +Odin's data is *latest value per key*. About 60 small records (about 1 KB each) are overwritten at 0.1-0.5 Hz +each. Two large records change rarely or never. After Cuts 1-3 the remaining cost is at most one 130 KB +atomic write and two fsyncs per second. + +- **Per-document** fits this shape: an overwrite touches only its own record. CultLib has two: + - C#'s `DirectoryMessagePackBackingStore` (`cultcache.store.v4.directory-content-addressed-pages`: a small + hot manifest plus cold content-addressed pages). It is the reference format. + - cultcache-rs `RedbMessagePackBackingStore`. The probe measured 42.6 KB written and 5-9 ms per small-row + commit. + + After the cuts, this saves about 90 KB/s. The fsync count, which is the stall that matters, stays the same: + 1 per commit against 1 per flush. The costs: + - redb is not the `.cc` format, and it has no lockless read-only crossing. Idunn's read of the correlation + file would break until A3. + - The v4 directory store does not exist in Rust. That is a parity gap in CultLib (F1), not a reason for + Odin to grow one. +- **Append-only (log plus compaction)** fits journals: ordered history that consumers replay, such as + CultNetDatabase shard logs. For Odin, the log grows at the overwrite rate (about 5 records/s, several hundred + MB/day), so it needs compaction. Idunn's crossing would have to replay it, and it needs a new on-disk format + with byte parity across four runtimes. It buys Odin nothing that a keyed store does not. + - The capability is already owed elsewhere. The publication campaign recorded (MORNING log, 06:23 and + earlier) that CultLib's file log store "truncates then rewrites with no atomic rename, so an append-only + atomic store must exist before any live daemon turns durable shard logs on." + - That is CultLib's append-only consumer. Odin should not be its first one. + +**Recommendation:** Cuts 1-3 (subtraction), then stop. Take up per-document storage for Odin only if the flush +interval must shrink far below 1 s, or if large documents must live in Odin's store (Q1 B). In either case the +store comes from CultLib, as the Rust port of the v4 directory store, and never as an Odin-local helper. + +## 6. Verification after each deploy (disk IO) + +Run on Yggdrasil (read-only; it reads no store): + +```sh +pid=$(pgrep -x odin-daemon | head -1) +snap(){ sudo cat /proc/$pid/io | awk '/^(rchar|wchar|syscw):/{printf "%s ", $2}'; \ + sudo ss -lunpm | grep -A1 "pid=$pid," | grep -o 'd[0-9]*)' | tr -d 'd)'; } +a=$(snap); sleep 60; b=$(snap) +echo "$a" "$b" | awk '{printf "read %.2f MB/s write %.2f MB/s writes %.2f/s drops %d/min\n", ($5-$1)/60e6, ($6-$2)/60e6, ($7-$3)/60, $8-$4}' +cat /proc/pressure/io +sudo journalctl -u idunn-yggdrasil.service --since -1h --no-pager -o cat | grep -c "admitted odin route continuity: timed out" +``` + +| After | read MB/s | write MB/s | writes/s | drops, route timeouts | +|---|---|---|---|---| +| baseline (1.1) | 106 | 16.2 | 5.0 | 136,291 lifetime; 42 timeouts in 12 h, 0 in the last hour at 10:2x UTC | +| Cut 1 | at most 17 | about 16 (unchanged) | about 5 | should fall (loop no longer decodes about 30 times a second) | +| Cut 2 | at most 3.5 | at most 3.5 | at most 1.05 | at most 1 burst per hour; 0 timeouts over an hour | +| Cut 3 | at most 0.3 | at most 0.2 | at most 1.05 | 0 drops per minute on average | + +Also run the post-deploy catalog check (`scratchpad/odin-postdeploy-check.sh`, Soul 2026-09-30). It proves no +provider type stopped being served. After Cut 3 the two surface types are expected to answer with zero +records; update the check's type list in the same pass. + +## 7. Operator questions + +- **Q1. The two 1.5 MB Eve documents.** + - A: CultMesh refuses any put it could never serve, and Odin drops the stored ones at activation. Anyone who + needs big surfaces through Odin gets the content plane (BP-1 in Rust) as its own campaign. + - B: keep storing them, and move Odin to a per-document store (port the v4 directory store to Rust first) so + they stay cold. + - C: leave them, and accept about 3.2 MB per flush. + + **Recommended: A.** Nobody can read them today. They are 93% of the store and of every write. The server's + put and serve limits disagree, and A fixes that at its owner. +- **Q2. May Imagination or Soul decode a copy of `topology.cc`?** A read-only decode of a *copy*, as approved + for Idunn's `control.cc`, to name the two large records (key, `stored_at`, publisher) and count the presence + history (F4). The publisher's name decides who is told that their puts will be refused under Q1 A. + **Recommended: yes**, before Cut 3 lands. +- **Q3. Flush interval and durability.** + - A: 1 s. An Odin crash loses at most 1 s of catalog and presence changes. Providers republish, puts carry + no application acknowledgement, and no published correlation sequence can repeat. + - B: 250 ms. This means up to 4 flushes and 8 fsyncs per second. + - C: flush correlations immediately and everything else at 1 s. + + **Recommended: A.** It is one rule, and Idunn's correlation freshness window is 30 s. +- **Q4. CultLib parity: port `DirectoryMessagePackBackingStore` (v4) to cultcache-rs?** It is not needed by + Odin after Cuts 1-3. A reasonable consumer of the Rust runtime would expect the reference's store. + **Recommended: record it as CultLib follow-up F1 with no deadline.** It is independent of this map. + +## 8. Related findings + +**Shared cause (folded in above):** +- **Route-challenge timeouts and the socket backlog.** The single-threaded loop blocks for 36-55 ms per + whole-store write, five times a second, and longer under IO pressure. Cuts 1-3 remove this. The 42 in 12 h + now (against about 1,100 in the audit) is consistent with the verify slots being cut from 8 to 5. Cut 2's + check measures it directly. +- **Eve surfaces over 1 MiB.** Cut 3 handles the storage side. *Serving* large surfaces is the content-plane + follow-up (F2). + +**Separate follow-ups (not caused by the write pattern):** +- **F1** CultLib: cultcache-rs lacks the C# v4 directory store (Q4). +- **F2** Eve surfaces larger than 1 MiB need the CultMesh content plane (BP-1 exists in Rust). This is a + campaign for whoever publishes them (Q2). +- **F3** `surface:gamecult.network.status` has had no publisher since the Node coordinator retired (09-11). + Gjallar (`Program.cs:562-592`), Hermodr, Mimir and VoidBot read it. The owner question (Odin's own Eve + surface or someone else's) is its own map. +- **F4** Presence history of withdrawn incarnations is never pruned (`lib.rs:2814` pins its retention). Only + Odin's own sequence continuation reads it, and only for Odin's own key. The store grows with every deploy. + Needs Q2's count, then a retention rule. +- **F5** Muninn republishes three catalog documents every 2 s with a fresh `updated_at` (Muninn `main.rs:101`, + `:3263`). Liveness belongs in its presence, not in catalog documents. After Cut 2 this costs Odin nothing + durable, but it is still traffic. +- **F6** Yggdrasil keeps 389 `idunn-odin-*` unit residues, nearly all `failed` (`systemctl list-units + 'idunn-odin*' --all`). They belong to Idunn and systemd, not to this map. +- **F7** `accept_raw_document` (`main.rs:269-280`) checks `activated()` but not the *current* lease before + mutating. Cut 2 moves the lease check to the only write, which closes this for the file. Soul should confirm + that nothing else acts on a lost lease. From ae8fa5571ffd0aeeaf3dedefa1b6126dc9d608b0 Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 13:09:31 +0200 Subject: [PATCH 17/34] Odin map: Q1 yes (refuse unservable puts), Q2 yes (decode a store copy) Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index e174014..f5283dd 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -3,7 +3,9 @@ **2026-09-30, Self.** Operator: "Let's fix Odin up, that's some furious bookkeeping right there. Look at Odin's write pattern and see if append only or per-document is the way to go." Answer: neither is the first fix. Cuts 1-2 go to Hands now. Q3 (a 1 s interval) and Q4 (the Rust directory store as a CultLib follow-up) are Self defaults, -and the operator may overrule them. Q1 and Q2 are put to the operator, and Cut 3 waits on Q1. +and the operator may overrule them. **Operator, 2026-09-30: "Q1: yes", so CultMesh refuses puts it could never +serve and Cut 3 goes ahead; "Q2: yeah, of course", so a read-only decode of a copy of live `topology.cc` is +approved. The copy is taken with `sudo cp` and decoded off the live path, and the copy is deleted afterwards.** Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. Anchors are Odin `main` at `44951a1` (the deployed build, live on Yggdrasil since 2026-09-30 04:20:48 UTC) and CultLib `3bf1c0ce` From 8c84857670deee7baab0bcebec7f7d910167e5fb Mon Sep 17 00:00:00 2001 From: soul-probe Date: Wed, 30 Sep 2026 13:22:58 +0200 Subject: [PATCH 18/34] Odin map: Q2 decode result; the oversize records are dead Ghostlight residue Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index f5283dd..42d5c83 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -7,6 +7,25 @@ and the operator may overrule them. **Operator, 2026-09-30: "Q1: yes", so CultMe serve and Cut 3 goes ahead; "Q2: yeah, of course", so a read-only decode of a copy of live `topology.cc` is approved. The copy is taken with `sudo cp` and decoded off the live path, and the copy is deleted afterwards.** +**Q2 decode result (Eyes, 2026-09-30; full table in the CultLib session scratchpad `odin-topology-decode.md`).** +- 77 records; 3,222,550 B of payload in a 3,240,078 B file. +- The two "~1.55 MB records" are per-type totals. Each type is dominated by one Ghostlight operator surface: + `eve:operator:ghostlight.campaign.27d444b3-...` (1,456,210 B) and its `operator-state` twin (1,456,341 B). + Together they are 89.9% of the file. +- Publisher: provider `gamecult.ghostlight.dungeon`. 99.5% of each record is one Eve `code` node holding a + pretty-printed JSON dump of the whole campaign state. +- These records are dead residue. They were stored 2026-09-23 and have not been rewritten since. Ghostlight `HEAD` + (`eeb3a68`) no longer contains the emitter (last touched at `6bb6869`, 2026-09-02). +- **So Q1's refusal breaks no live publisher.** Cut 3's Odin half drops exactly these two at activation. Every other + surface is 70 KB or less. +- Correction to section 3: the 1 MiB snapshot refusals in the journal are per-type responses, not per-record. + Cut 3's per-document bound is still right: each of these records is over 1 MiB alone. A type whose many small + records add up past 1 MiB would still be unservable as a whole-type snapshot. That is a follow-up for CultMesh + (paged snapshot responses), not part of this campaign. +- Presence history: 41 records, 42 KB, 37 of them withdrawn. F4 is a slow leak, not a size problem. +- The current `streampixels-web` incarnation has a correlation but no presence record; its last presence was + 09-29 19:53 UTC. This matches Idunn's "not Odin-correlated" state. + Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. Anchors are Odin `main` at `44951a1` (the deployed build, live on Yggdrasil since 2026-09-30 04:20:48 UTC) and CultLib `3bf1c0ce` (Odin's `Cargo.lock` pin). This map owns the means; there is no separate target document, because the ends are From 77373cb5635c2a69e8c8efb6aa79a16c648a7ed4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:06:06 +0200 Subject: [PATCH 19/34] Odin map: Q3 B (durable put ACK); foreign-write handling must repair and recover Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index 42d5c83..e1334a2 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -7,6 +7,18 @@ and the operator may overrule them. **Operator, 2026-09-30: "Q1: yes", so CultMe serve and Cut 3 goes ahead; "Q2: yeah, of course", so a read-only decode of a copy of live `topology.cc` is approved. The copy is taken with `sudo cp` and decoded off the live path, and the copy is deleted afterwards.** +**Soul on Cuts 1-2, and two operator rulings (2026-09-30).** +- **Q3 restated.** The premise "puts carry no application acknowledgement" was false. The cultmesh-rs server + ACKs a put only after `accept_raw_document` returns Ok, so after Cut 1 an ACK meant "in memory", and a SIGKILL + lost acknowledged puts in 6 of 6 probe runs. + - **Operator ruling: "Q3: B".** Peer puts are fsynced before they are acknowledged. Only Odin's own + bookkeeping (heartbeats, correlations) waits for the 1 s interval. An ACK means durable. +- **An undecodable foreign write.** Today Odin stays up, serves from memory and never persists again. Self offered + three options: degraded but loud, end Odin, or unchanged. + - **Operator: "None of these options involve repair and recovery, which is what ultimately needs to happen."** + - Recovery is mapped as its own pass. It covers an undecodable foreign write, a decodable one, and a failed + load at activation. + **Q2 decode result (Eyes, 2026-09-30; full table in the CultLib session scratchpad `odin-topology-decode.md`).** - 77 records; 3,222,550 B of payload in a 3,240,078 B file. - The two "~1.55 MB records" are per-type totals. Each type is dominated by one Ghostlight operator surface: From 1d9bc0246d755653438a6522a75a221ee34672a8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:30:37 +0200 Subject: [PATCH 20/34] Map Odin store repair and recovery Operator: "None of these options involve repair and recovery, which is what ultimately needs to happen." Q-R1..R6 open; R1 (CultLib typed read failures, temp cleanup) does not depend on them. Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 493 +++++++++++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 docs/store-recovery-cut.md diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md new file mode 100644 index 0000000..5efca27 --- /dev/null +++ b/docs/store-recovery-cut.md @@ -0,0 +1,493 @@ +# Odin store repair and recovery: cut map + +Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. This map owns the means for repairing +and recovering `/var/lib/gamecult/odin/topology.cc`. It follows `docs/write-pattern-cut.md` (Cuts 1-3) and starts +from that campaign's landed head. + +Anchors: +- Odin `hands/odin-write` at `cda2087` (Cuts 1-2 plus two test commits). Q3 B (peer puts fsynced before ACK) is + being finished on that branch now. **Re-anchor every Odin `file:line` below on the head that lands; the line + numbers here are `cda2087`.** +- CultLib `6d0cc963`. `packages/cultcache-rs` is byte-identical to Odin's pin `3bf1c0ce`, so the Rust line numbers + hold at both. +- Idunn `a96ad9d`, Heimdall `e8e2832`, gamecult-ops `runbooks/gamecult-state-backup.md` as of 2026-09-30. + +Operator, 2026-09-30, on the three options Self offered for an undecodable foreign write (degraded but loud, end +Odin, unchanged): **"None of these options involve repair and recovery, which is what ultimately needs to happen."** + +Open: Q-R1 to Q-R6 (section 8). D1 (section 6, O1) is a Self default the operator may overrule. + +## Short answer + +Odin already holds the one thing a repair needs: its in-memory working set, which is authoritative for everything +it owns while it holds the write lease. Today it never uses it for repair. It ends on a decodable foreign write, and +it silently stops persisting on an undecodable one. At activation it has no working set and no second copy, so it +crash-loops. + +The repair has one rule: **a store file that is not what Odin last wrote is set aside, never deleted and never +merged, and Odin writes its working set over it under the lease.** At activation Odin has no working set, so it +falls back to a previous generation kept on disk, and then to an empty store that providers refill. The +correlation sequence gets a time floor, so no rollback can recreate the watermark deadlock. Recovery is published as +a typed record in Odin's own catalog. The mechanism (typed read failures, a set-aside exchange, a retained previous +generation, no staging file left behind by a failed write) belongs to CultLib, because every consumer of the +single-file store has the same gap. + +## 1. Body facts (probed) + +### 1.1 Probe: cultcache-rs single-file store under fault + +The probe was a scratch crate at CultLib `3bf1c0ce`, `scratchpad/odin-recovery-probe` (Imagination session), commit +`b19eba9`, run through the stopgap on Yggdrasil in slot 2. It ran with `--tmpfs /small:size=64k --tmpfs /ro:ro`, +using `trap '' XFSZ; ulimit -f 40` for the file-size case. The log is `scratchpad/odin-recovery-probe.log`. + +| Fault | Result | +|---|---| +| P1: file truncated to half | `compare_exchange_snapshot` and `pull_all` return `Err`, **not** `Ok(false)`. There is no `io::Error` in the chain; the decode error is flattened to text (`lib.rs:720-725`). The message is wrong: "invalid type: string \"cultcache.store.v1\", expected struct CultCacheEnvelope". `store_header` (`lib.rs:317-341`) deserializes the *whole* array to find the header, so a truncated v1 file has no header and falls through to the legacy decoder. The file is unchanged by the failed exchange | +| P2: 12 bytes of garbage appended | `pull_all` returns `Ok`, all 3 records. `compare_exchange_snapshot(expected)` returns **`Ok(true)` and silently drops the tail**. `rmp_serde::from_slice` ignores trailing bytes. C#'s `DeserializeSnapshot` (`CultDocumentMessagePackSerialization.cs:179-215`) never checks for the end either | +| P2b: one byte flipped inside a payload | `pull_all` returns `Ok` (payloads are opaque bytes). `compare_exchange_snapshot` returns **`Ok(false)`**. To Odin, bit rot is a *decodable foreign write* | +| P3: header `cultcache.store.v4...` | `Err` with a distinct message, "not readable; this runtime reads v1 only". It is distinguishable only by its text | +| P4: another handle wrote a valid snapshot | `Ok(false)` | +| P5: `hard_link(topology.cc, topology.cc.displaced..x)`, then an exchange through the store | The link keeps the old bytes exactly, and the store writes the new file. The staging sweep (`remove_abandoned_staging_files`, `lib.rs:2730-2766`) removes a `..tmp` beside it and leaves the displaced name alone | +| read-only filesystem, no store | `pull_all` returns `Ok(0)`. The exchange fails opening the lock: `io::Error` `ReadOnlyFilesystem` (os 30) | +| `RLIMIT_FSIZE` 40 KiB, 200 KB write | `Err` `io::Error` `FileTooLarge` (os 27). The old file is intact and still decodes. **A 40,960 B partial staging file is left behind.** The next attempt sweeps it and leaves a new one | +| 64 KiB tmpfs, 200 KB write (ENOSPC) | `Err` `StorageFull` (os 28). The old file is intact. **A 61,440 B partial staging file is left behind: all of the filesystem's remaining space.** The same holds on every retry | + +What follows from the probe: +- **Decodable is not the same as legitimate.** A flipped payload byte is "decodable and foreign", and appended + garbage is "decodable and ours". The decodable/undecodable split cannot be what decides recovery. One rule has to + cover both: *not what Odin last wrote*. +- **A write that fails for lack of space holds the space it failed on.** `/var/lib/gamecult` is shared by every + service on Yggdrasil. Odin retries once per second (and, under Q3 B, on every put), so it re-takes whatever + another service frees. C# deletes its temp file in a `finally` (`CultCache.cs:3496-3531`); Rust does not + (`write_all_unlocked`, `lib.rs:741-768`). This is a Rust parity defect. +- **The atomic write keeps no previous generation.** `write_all_unlocked` stages, fsyncs, renames over the file and + fsyncs the directory. After the rename, the old inode is gone. The brief's "last good checkpoint kept by the + atomic write" does not exist on disk. The only last-good copy is Odin's in-memory `flushed` snapshot + (`lib.rs:506-510`), and it dies with the process. + +### 1.2 Odin today (`cda2087`) + +| Case | Path | Outcome | +|---|---|---| +| Decodable foreign write while serving | `MemoryOdinTopologyStore::flush` `lib.rs:613-626`: `compare_exchange_snapshot(flushed, current)` returns `false`, so `ForeignStoreWrite`. `survive` `main.rs:879-895` returns it, and Odin ends | Idunn restarts Odin (`control_plane.rs:68-71`: 6 restarts per hour, the wait doubling from 5 s). The new Odin **loads the foreign file as truth**. Odin's own unflushed changes, and every record of Odin's that the foreign file lacks, are lost without a trace. A flipped payload byte (P2b) takes this path | +| Undecodable foreign write while serving | the same exchange returns `Err` (decode). `survive` logs it and retries every second, forever. Nothing is persisted again | **Idunn's crossing breaks too.** `receive` (`Idunn drivers.rs:5005-5030`) reads the same file and propagates the error to `admit_latest_topology` (`control_plane.rs:7903-7907`) and `refresh_admitted_topology` (`:6040`). Every Odin-correlated deployment and refresh fails until the file is readable, which is never | +| Corrupt file at activation | `try_activate` `main.rs:246-297` calls `MemoryOdinTopologyStore::load` `lib.rs:519-538`, which calls `pull_all()?`. `main.rs:762` `while !try_activate()?` exits the process | Crash loop until Idunn's budget is spent. Then Idunn reports once, to its log only (`control_plane.rs:5709-5723`, `report_once` `:4599-4610` is `eprintln`), and stops. The Verse rendezvous is down | +| Disk full or read-only | the exchange returns `Err` (io). `survive` logs and retries every second | Odin serves from memory. Each attempt leaves a partial staging file (1.1). A stop or deploy in this state loses everything unflushed. Nothing typed says so | + +Detection latency for a foreign write: `flush` writes only when dirty (`main.rs:307-314`). Odin's own heartbeat +(`HEARTBEAT_INTERVAL` 5 s, `main.rs:69`) dirties the set, so a foreign write is met at the latest one heartbeat plus +one flush interval (about 6 s) after it happens. No polling read is needed, and Cut 1's rule (no reads while +serving) stands. + +### 1.3 Other sources of truth, and what they can recover + +| Source | Holds | Freshness | Recovers | +|---|---|---|---| +| Odin's working set (W) | everything, including unflushed changes | live | everything, but only while this process lives and holds the lease | +| `flushed` (in memory) | the last snapshot Odin wrote | at most one interval old | nothing more than W; it is the comparison base, not a store | +| previous generation on disk (new, O3 and R3) | the snapshot before the last one this store's owner verified and replaced | one write old | everything except the last write's delta, including publish-once documents, presence history and the watermark | +| nightly backup (`gamecult-state-backup.md`) | `/var/lib/gamecult/odin` whole, shared-lock copy | up to 24 h old, 3 days on Yggdrasil, 14 days plus 8 weekly on Raven | publish-once documents. **Never automatically**: "It never becomes live authority, and nothing restores it automatically"; "restore a store only after stopping its owner" | +| providers republishing | presences (2.7-12 s), Muninn documents (2 s), ads (about 30 s); Heimdall HEAD republishes `heimdall.command_boundary` and its three siblings every 60 s (`Heimdall src/index.ts:19,35-39`, gated on its write lease) | seconds to a minute | every document whose publisher republishes | +| Idunn's projection plus presences | correlations | one refresh pass (250 ms) after the presences return | correlations. They are a projection and never need restoring | +| Idunn's `odin_publisher_sequence_cursor` (`control_plane.rs:1456,1537,6081`) | the highest correlation sequence Idunn admitted, per target | live | nothing directly (Idunn owns it), but it is why the watermark must never roll back | + +Premise correction: `heimdall.command_boundary` is not publish-once at Heimdall HEAD. A `stored_at` of 09-23 +proves only that the publisher's own `stored_at` has not changed, because Odin stores the publisher's +`stored_at` (`persist_generic_document`, `main.rs:1330-1347`). Whether the deployed Heimdall re-puts is +unverified; O3's outstanding-republish list measures it. + +**The watermark is the one record whose loss is not self-healing.** Idunn refuses any correlation sequence at or +below its cursor (`control_plane.rs:1763,1785`). An Odin that starts from an empty store, from the previous +generation, or from a backup restarts a target's count below that cursor. That is the deadlock documented at +`lib.rs:33-49`: Idunn refuses every correlation until the count climbs past the old mark, and the refusal +withdraws the correlation before it can climb. Every recovery source except W has this defect. O1 removes it. + +Other facts the design leans on: +- **Idunn fences before it grants.** `CultCacheWriteLeaseDriver` (`Idunn drivers.rs:4050-4205`): "revocation + follows an exact incumbent stop", and grant is a nonblocking exchange from empty. A second *Odin* cannot hold + the lease while the first is still writing, so a foreign writer seen by a lease-holding Odin is not an Odin. +- **Idunn has a lifecycle brake** (`idunn-provision lifecycle-brake-engage|release|status`, + `provisioning.rs:95-97`; `odin-lifecycle-brake.cc` per `runbooks/odin-yggdrasil.md:29-30`). An engaged brake + denies continuity restarts (`control_plane.rs:5771-5774`, `:6219`). This is the operator's hold for a manual + restore. +- **A non-`active` Odin presence fans out.** `reconcile` sets `ready` only when the presence state is `active` + (`lib.rs:946`), and `dependency_evidence` (`lib.rs:1184ff`) makes every managed dependent of Odin not Ready. + The presence schema allows `degraded` (`cultnet-rs runtime_authority_contracts.rs:193`), but using it would + be a readiness statement about every dependent, not a store report. +- **The catalog serves only peer types and presences** (`public_document`, `main.rs:590-640`). No `odin.*` record + is served today, and neither Odin nor Idunn publishes an Eve surface. F3 in the write map (the unowned + `surface:gamecult.network.status`) is where an Eve lowering belongs. + +## 2. Ends + +1. No single fault, whether a foreign write of any kind, a corrupt file, or a failed write, ends Odin or leaves it + silently unpersisted. Only losing the write lease ends it. +2. A store file that is not what Odin last wrote is preserved byte for byte beside the store and never deleted, + merged or adopted by any automatic path. The owner (Odin under the lease) writes its own state over it. +3. At activation a corrupt store is recovered from the best source on the host (previous generation, then empty), + and never from the backup automatically. +4. No recovery can make a correlation sequence repeat or fall below one Idunn has admitted. +5. Every repair, and every stretch in which Odin is serving without persisting, is typed state that can be + queried through Odin's catalog while it is happening, including when the disk refuses writes. + +Invariants carried over: one writer (the lease, checked immediately before every write); Idunn's lockless +crossing reads a whole `.cc` snapshot at `topology.cc`; the path never becomes absent (Idunn reads an absent file +as an empty snapshot, which withdraws every correlation); no reads of the store while serving; one bad record +never stops Odin. + +## 3. Identity, lifecycle, authority + +| Persistent kind | Named by | Over time | Decided by | +|---|---|---|---| +| store file `topology.cc` | the state root plus `TOPOLOGY_SLOT` | replaced whole by the owner's exchange; never absent once created | Odin under the lease | +| displaced file (new) | `topology.cc.displaced..`, same directory (it must not end in `.tmp`) | created by a set-aside exchange as a hard link to the replaced inode; never modified; never deleted by any program. Travels to Raven with the nightly backup | created by Odin through CultLib; **disposed of only by the operator** | +| previous generation (new, Q-R2) | `topology.cc.previous` | replaced on every *verified* exchange (current == expected) by a hard link to the outgoing inode; **not** rotated by a set-aside exchange, so it always holds a generation that some writer verified before replacing | CultLib mechanism; Odin opts in | +| store condition record (new) | type `odin.store_condition`, schema `odin.store_condition.v1`, key `topology` | one record held in W; rewritten when the condition changes; flushed with the store; served by the catalog | Odin | +| correlation sequence floor (new rule, not a record) | per target, in the existing watermark | next = max(mark + 1, prior + 1, now in unix ms) | Odin | +| backup snapshot | `/var/backups/gamecult-state//` and Raven | nightly; never live authority | the operator restores it, with the lifecycle brake engaged (runbook) | + +## 4. Authority map (the whole campaign) + +- **Owner:** Odin, while it holds the current process-write lease, decides what `topology.cc` holds, when it is + set aside, and what activation recovers from. CultLib owns the mechanism (read classification, set-aside + exchange, previous generation, staging cleanup) and no policy. Idunn owns whether Odin runs (continuity budget, + lifecycle brake, deploys). The operator owns restores from backup and the disposal of displaced files. +- **Inputs:** W; `flushed`; the lease (read immediately before every write); at activation, `topology.cc` and then + `topology.cc.previous`; the clock (the sequence floor). +- **Outputs:** one atomic `topology.cc`; displaced files; `topology.cc.previous`; the `odin.store_condition` record + in the catalog. +- **Derived state:** `topology.cc` is a checkpoint and Idunn's crossing, never an input while serving. The + displaced files are evidence only: nothing reads them automatically. The previous generation is read only by + activation recovery. The condition record is a report and decides nothing. + - `ForeignStoreWrite` **is no longer an owner of Odin's life**. A foreign write becomes a set-aside event. +- **Forbidden writers and deciders:** + - Any path that ends Odin on a store condition. `survive`'s `ForeignStoreWrite` arm (`main.rs:884`) is + deleted; `WriteLeaseLost` stays, and so does the temporary `PresenceAuthorityRefused` rule. + - Any automatic delete of a displaced or previous file. + - Any merge of records from a foreign or displaced file into W. + - Any automatic restore from the backup. + - Idunn writing or repairing `topology.cc`. + - Any second writer of the file inside Odin: activation never writes. It prepares W and `flushed`, and the one + `flush` makes the first write through the same set-aside exchange. +- **Shared paths:** + - The interval flush, the Q3 B per-put flush, the stop flush and the activation-recovery first flush all go + through one primitive, `replace_snapshot_setting_aside`. + - Every path that reserves a correlation sequence goes through the one floored reservation. +- **Deletion line:** `ForeignStoreWrite` (`lib.rs:630-641`) and its `survive` arm are deleted before the set-aside + exchange is wired in. The `Err` escape in `load` (`lib.rs:521`, `pull_all()?`) is replaced by the recovery + ladder, not wrapped. + +## 5. Recovery by case + +| Case | Detected by | Action | Recovered from | Loss bound | Visible as | +|---|---|---|---|---|---| +| **A. Decodable foreign write while serving** (a hand edit, a tool, a restore made against the runbook, or bit rot in a payload per P2b) | the next flush: current != `flushed` | the lease is checked; the file is set aside (hard link); W is written; Odin keeps serving | W | none of Odin's own records. The foreign file's content is preserved, not adopted | condition event `displaced`, reason `mismatch`, with path, sha256, bytes and time | +| **B. Undecodable foreign write while serving** (truncation, garbage, a foreign format, and after R2 trailing bytes too) | the next flush: current does not decode | the same | W | none. Idunn's crossing is unreadable for at most one heartbeat plus one flush (about 6 s), instead of forever | the same, reason `undecodable` | +| **A/B repeated** (a live foreign writer) | a third set-aside inside 10 minutes in one activation | Odin stops writing and keeps serving; puts are not ACKed (Q3 B: ACK means durable); no new displaced files (Q-R4) | W stays in memory | nothing while the process lives | condition `contested`, since when, with the displaced files | +| **C1. Undecodable store at activation** | `load` gets `Undecodable` | W is built from `topology.cc.previous` if it decodes; `flushed` is set to "unreadable", so the first flush sets the corrupt file aside and writes W | the previous generation | the last write's delta: one interval of Odin bookkeeping, or one fsynced put. **A put ACKed as durable can be lost here; say so in Q3 B's record** | event `recovered-previous`, plus an outstanding-republish list: peer records in the previous generation not yet re-put by their publisher, cleared as they arrive | +| **C2. Nothing decodable at activation** | `.previous` absent or undecodable too | start empty (Q-R3); the first flush sets the corrupt file aside | providers republish; correlations re-derive in one pass; the sequence floor holds | every publish-once document; presence history (F4 makes this harmless) | event `started-empty`; outstanding list is `unknown`. The operator's path is the backup, with the lifecycle brake engaged | +| **C3. Unsupported format at activation** (a newer runtime wrote v4, or a downgrade) | `load` gets `UnsupportedFormat` | refuse to activate. The file is untouched and nothing is set aside | the deploy or rollback that caused it; Idunn owns that | none | Idunn's continuity budget and the Odin journal. This is a deployment fault, not a store fault | +| **C4. I/O error reading the store at activation** (EIO, EACCES after a restore with the wrong owner, EISDIR) | `load` gets an `io::Error` | refuse to activate. Nothing is set aside: **only bytes that were read are ever judged** | the operator fixes the host or the mode | none | as C3 | +| **D. Write fails while serving** (ENOSPC, EDQUOT, EFBIG, EROFS, EIO) | the exchange returns an `io::Error` while staging or renaming | nothing is set aside; the staging file is removed (R1); W is kept; retry at the flush interval; puts are not ACKed; Odin keeps serving | W, when the fault clears | nothing, unless Odin is stopped or redeployed before the fault clears (follow-up I-F1) | condition `unpersisted` since T, with the last error kind (`no-space`, `read-only`, `io`) and the dirty-record count, served from memory | +| **D'. Stop while unpersisted** | the stop flush fails | log it and stop, as today (`main.rs:794-799`) | the next activation loads the last good file | the unflushed delta | the final journal line only. I-F1 is the owner fix | + +Rollback safety, for C1, C2 and an operator restore: every source except W carries a watermark below Idunn's +cursor. The floor (O1) makes the next sequence exceed every sequence published before, whatever the store holds. + +## 6. Cuts + +Order: the Rust substrate gaps first (R1), then the watermark floor (O1, independent and small), the set-aside +primitive (R3), Odin's serving repair (O2), activation recovery (O3), parity hardening (R2, R3b), and finally the +runbook and drill (X1). Each cut is its own branch and its own Soul pass. Odin verification: +`cargo test --locked -p odin-daemon` from the Odin root. CultLib Rust: `cd packages/cultcache-rs && cargo test +--locked`. All of it runs on Yggdrasil through the stopgap. **A permission-based test is unproven there (root).** +The injections below avoid permissions: truncation, a directory at the store path (EISDIR), tmpfs size and +read-only options, and `RLIMIT_FSIZE`. + +### R1. cultcache-rs: typed read failures, no staging file left behind, a header that survives truncation (CultLib) + +- **Repo/branch:** CultLib `hands/cultcache-rs-store-faults` from `main`. Depends on nothing. +- **Deletes first:** the flattening `map_err(|error| anyhow!(...))` in `read_all_unlocked` (`lib.rs:720-725`); + `store_header`'s whole-array visitor (`lib.rs:317-341`). +- **Adds:** + - `pub struct CultCacheStoreUnreadable { path, kind: Undecodable | UnsupportedFormat }`, carried in the error + chain of every read of a single-file store whose bytes were read and not accepted, so consumers can + `downcast_ref`. An `io::Error` stays an `io::Error`, and the two never overlap. + - `store_header` reads only the array header and the first string, reusing `read_array_header` and + `read_string` (`lib.rs:2661-2700`). A truncated v1 file then reports as an undecodable v1 file, not as legacy. + - `write_all_unlocked` removes its own staging file on every failure after creating it. This is C# parity with + `CultCache.cs:3525-3530`. +- **Rules that must die under their own mutation:** + - a truncated store is `Undecodable`, and a v4 header is `UnsupportedFormat`; + - an `io::Error` read failure is never classified `Unreadable`; + - after a failed write, the directory holds no `.*.tmp`. +- **Verification:** + - Tests describe behaviour. The staging-cleanup test forces the failure without permissions. One option is a + child process under `RLIMIT_FSIZE` with SIGXFSZ ignored (the probe's method); another is a path whose parent + fills a size-capped tmpfs, if the test runner can mount one. Hands picks one and names it. + - `cargo mutants --in-diff`. +- **Estimate:** about -30 / +70. + +### O1. The correlation sequence has a time floor (Odin) — default D1 + +- **Repo/branch:** Odin `hands/odin-sequence-floor` from the landed `hands/odin-write` head. Depends on nothing + else. +- **Change:** `reserve_publisher_sequence` (`lib.rs:702-723`) returns max(mark + 1, prior + 1, now in unix ms), + and records it as the mark. The doc comment at `lib.rs:33-49` gains the rule: "A mark read from any older copy + of the store is a floor, never the next value; the clock makes every rollback safe." +- **Why a floor and not a read of Idunn's cursor:** it needs no new crossing. It holds for every recovery source, + including the backup, and the host clock is already trusted for presence freshness. +- **Assumption:** the clock does not step back across a rollback by more than the rollback's age. Soul checks that + Idunn accepts a jump: its rule is "strictly above the cursor" (`control_plane.rs:1763,1785`). No test may pin a + gap limit. +- **Verification:** + - Existing tests pinning counts (`withdrawing_a_correlation_does_not_reset_its_publisher_sequence` + `lib.rs:1996-2014`) are restated as order properties. + - New test: a store rolled back to an earlier snapshot, then one refresh, yields a correlation sequence greater + than every sequence published before the rollback. + - New test: two reservations inside one millisecond are strictly increasing. + - The clock must be injectable at the reservation. At least one mutant must be a function of the time (an offset + or a scale), with probe values where it would show. +- **Estimate:** about -5 / +25. + +### R3. The set-aside exchange and the previous generation (CultLib, Rust and C#; TS and Python in R3b) + +- **Repo/branch:** CultLib `hands/cultcache-set-aside` from R1. Q-R2 decides the previous-generation half. +- **Adds, on `SingleFileMessagePackBackingStore`:** + - `replace_snapshot_setting_aside(expected, replacements) -> Result`, with + `SnapshotReplacement::{Exchanged, SetAside { displaced: PathBuf, sha256, bytes, reason: Mismatch | + Undecodable }}`. It makes one exclusive lock hold, in this order: + 1. stage the replacement (write and fsync); + 2. read current; + 3. if current equals expected, rename the staged file over (rotating `.previous` first when enabled); + 4. if the read was an `io::Error`, remove the staging file, return the error and change nothing; + 5. otherwise hard-link current to the displaced name, fsync the directory, rename the staged file over, and + fsync the directory again. + + Staging comes first, so a full disk sets nothing aside. The path is never absent: a link, never a rename. A + set-aside never rotates `.previous`. + - Opt-in previous generation: a constructor option (`retaining_previous_generation`, or Hands' spelling of it) + plus `pull_previous_generation() -> Result>`, read under the shared lock and classified + like any read. Off by default, because existing consumers must not grow files they did not ask for. + - Filesystems without hard links: return an explicit error naming the filesystem. Do not fall back to a + copy-then-rename, which would make the path briefly hold new content while the evidence is still being + written. Windows NTFS links work; the Windows path is **not yet reached** by the Yggdrasil suite and is + recorded as such. + - C# reference: the same operation on `SingleFileBackingStore` (`CultCache.cs:3268ff`), with the same names + across runtimes. +- **Verification (Rust and C#):** + - a mismatch preserves the foreign bytes exactly and writes the replacement; + - an undecodable current is preserved and replaced; + - an I/O read failure (a directory at the path) changes nothing and leaves no staging file; + - a staging failure (size cap) changes nothing and sets nothing aside; + - the path is readable at every instant: a reader thread loops `pull_all_read_only_snapshot` through 1,000 + exchanges and never sees an absent file or an empty snapshot; + - `.previous` rotates only on a verified exchange; + - two set-asides in the same second get distinct names; + - mutation tools: `cargo mutants --in-diff`, and Stryker.NET on the C# diff. +- **R3b (parity, not blocking Odin):** cultcache-ts `single-file-messagepack-backing-store.ts:34` and cultcache-py + `stores.py:83` gain the same operation and option, with a shared fixture test. +- **Estimate:** Rust about +140, C# about +120, R3b about +200. + +### O2. A foreign store file while serving is set aside, not fatal (Odin) + +- **Repo/branch:** Odin `hands/odin-store-repair` from O1, with a CultLib pin bump to R3. Needs Q-R1 and Q-R4. +- **Deletes first:** `ForeignStoreWrite` (`lib.rs:630-641`) and its arm in `survive` (`main.rs:884`). The test + `a_flush_writes_only_changes_and_never_over_a_foreign_file` (`lib.rs:2055-2090`) and the `survive` tests at + `main.rs:2842-2844` and `:3110` pin the old rule: restate them, do not delete them. +- **Changes:** + - `MemoryOdinTopologyStore::flush` (`lib.rs:613-626`) calls `replace_snapshot_setting_aside(flushed, current)`. + On `SetAside` it records a `displaced` event and counts it toward the contested bound. On an I/O failure it + records `unpersisted` and returns the error, which `survive` logs and retries as today. + - `RuntimeState::flush` (`main.rs:307-314`) keeps its lease check immediately before the write. + - The Q3 B per-put path uses this same flush. Hands re-anchors on the Q3 B head. +- **Adds:** + - The `odin.store_condition` record (schema in `odin-core` `documents.rs`), held in W. It carries: + - `generation` and `written_by`, updated inside the flush before the snapshot is taken; + - `unpersisted_since` and `last_write_error`; + - `contested_since`; + - a bounded list of the last 16 recovery events: kind, time, displaced path, sha256 and bytes, source, records + loaded, and the outstanding list (bounded). + - One arm in `public_document` (`main.rs:590-608`) that serves exactly this type. Providers still cannot write + it: `is_peer_document_type` is unchanged. +- **Rules that must die under their own mutation:** + - a flush never leaves the store dirty (the condition record is written inside the flush, not after it); + - a set-aside is preceded by a lease check; + - the third set-aside inside the window stops writing, and the second does not; + - after `contested`, no file is written or created; + - on an I/O failure, nothing is set aside; + - the condition is served while writes fail. +- **Authority map:** as in section 4. The deletion line is the `ForeignStoreWrite` type and arm. +- **Verification (fault injection, each at the file layer, not a log line):** + - T-A: activate, serve, write a different valid snapshot behind Odin. After the next pass the file decodes to + W, the displaced file's bytes equal the foreign bytes, `serving_pass` returned `Ok`, the catalog is unchanged, + and the served condition names the displaced file and its sha256. + - T-A2 (P2b): flip one payload byte behind Odin; the same outcome, reason `mismatch`. + - T-B: truncate the file behind Odin; the same outcome, reason `undecodable`, and + `pull_all_read_only_snapshot` (Idunn's crossing) decodes the file after one pass. + - T-B-latency: with no puts, the set-aside happens within one heartbeat plus one flush interval. + - T-contested: three foreign writes inside the window make exactly two set-asides and a third `contested` + report. After that the file and directory are untouched by further passes, and Odin still serves. + - T-D: replace the store path with a directory (EISDIR, which works as root). Nothing is set aside, no staging + file remains, the condition is `unpersisted` and is served, and a put is not ACKed. Remove the directory, and + the next pass writes W, clears `unpersisted` and ACKs. + - T-lease: the lease withdrawn before a flush that would set aside means no link, no write, and Odin ends + (`WriteLeaseLost`). + - Negative: `rg -n "ForeignStoreWrite" crates` matches nothing; `rg -n "remove_file|remove_dir" crates/odin-daemon/src` + matches nothing new outside tests. + - `cargo mutants --in-diff`. +- **Estimate:** about -40 / +160. + +### O3. Activation recovers a corrupt store (Odin) + +- **Repo/branch:** Odin `hands/odin-store-activation` from O2. Needs Q-R2 and Q-R3. +- **Change:** `MemoryOdinTopologyStore::load` (`lib.rs:519-538`) becomes the recovery ladder: + + | `load` result | Outcome | + |---|---| + | `Ok` | as today | + | absent | empty, as today | + | `Unreadable::Undecodable` | try `pull_previous_generation()`, then empty (Q-R3); `flushed` becomes a sentinel meaning "the file is not a snapshot Odin can compare", so the first flush's exchange sets it aside | + | `Unreadable::UnsupportedFormat` | return the error: refuse | + | `io::Error` | return the error: refuse | + + Activation itself never writes. The first `serving_pass` flush (`main.rs:833-842`, due at once) writes W. That + set-aside is recorded under the activation event and is not counted toward the contested bound. +- **Adds:** the outstanding-republish list. It holds the peer-type records present in the recovered previous + generation, each cleared when its publisher re-puts it. After 10 minutes the remainder is marked "not + republished", which is the live answer to "who is publish-once". For `started-empty` the list is `unknown`. +- **Rules that must die under their own mutation:** + - Unsupported-format and I/O failures never set aside, never fall back, and never start. + - A recovered activation writes before it serves its first catalog snapshot, or it serves the recovered set and + marks the condition before the first write. Hands picks one and states it. +- **Verification:** + - T-C1: a truncated `topology.cc` and a valid `.previous` activate with the previous content. The first pass + writes it and sets the corrupt file aside byte-exact, and the condition is `recovered-previous` with its + outstanding list. Re-putting one listed document clears it. + - T-C2: both undecodable (Q-R3 A) start empty. Both files are preserved, the condition is `started-empty`, and + the next correlation sequence is above the floor. + - T-C3: a v4 header refuses to activate. The file is byte-identical afterwards, and there is no displaced file + and no `.previous` change. + - T-C4: a directory at the store path refuses, as C3. + - T-restart: a recovered Odin stopped and restarted loads its own first write normally, with no second + set-aside. + - `cargo mutants --in-diff`. +- **Estimate:** about -10 / +110. + +### R2. Single-file decoders reject trailing bytes, in every runtime (CultLib) + +- **Repo/branch:** CultLib `hands/cultcache-trailing-bytes`. Independent of Odin; land it before O3 ships so that + an appended file is caught as undecodable, not overwritten silently (P2). +- **Change:** C# `DeserializeSnapshot` (`CultDocumentMessagePackSerialization.cs:179-215`) checks `reader.End`. + Rust `decode_store_snapshot` (`lib.rs:2583ff`) and the legacy path check that the slice is consumed. TS and + Python do the same. **One release**: a runtime that accepts what another rejects is a parity split. +- **Verification:** one shared fixture (a valid store plus one byte) is rejected as undecodable by all four + runtimes, and the valid store is accepted by all four. +- **Estimate:** about +40 plus tests across four runtimes. + +### X1. Runbook and recovery drill (gamecult-ops, then live) + +- **Repo:** gamecult-ops `runbooks/odin-yggdrasil.md` gains a "Store recovery" section: + - how to read `odin.store_condition` from the catalog; + - what displaced and previous files mean, and that disposing of them is the operator's decision; + - the manual restore: engage Odin's lifecycle brake, stop the `idunn-odin-*` unit, set the live file aside with + `ln` (never `rm`), extract the snapshot's `topology.cc` with its owner, mode and `.lock`, release the brake, + and read the condition. The sequence floor makes an old watermark safe. +- **Drill D-1 (off-live, after O3; needs the Q2-style approval, Q-R6):** extract the newest Yggdrasil snapshot's + `topology.cc` into a container on Yggdrasil. Activate a test-harness Odin on it through the existing fixture + authority. Then truncate the copy and activate again with the extracted file as `.previous`. Report the records + per type, the outstanding list and the sequence floor. Delete the copy afterwards. +- **Drill D-2 (live, operator-scheduled, Q-R6), after O3 deploys:** + 1. Record the baseline: catalog types, Idunn correlated targets, route timeouts. + 2. Engage the lifecycle brake and stop Odin. + 3. `ln topology.cc /var/tmp/odin-drill-keep.cc` (the evidence of the evidence), then truncate `topology.cc` in + place to half. + 4. Release the brake and let Idunn restart Odin. + 5. Pass when all of these hold: + - Odin is serving within one restart; + - the condition is `recovered-previous`, with a displaced file whose sha256 equals the truncated file's; + - every Odin-correlated target is Ready again within 60 s; + - there are 0 route-challenge timeouts in the next hour; + - the catalog's type set equals the baseline after 10 minutes, or the outstanding list names every + difference. + 6. Then run D-2b, a foreign write while serving: `ln` the live file aside, then truncate it. It passes when the + condition shows `displaced` within about 6 s and Idunn's correlation reads never fail for longer than that + (the journal). + +## 7. Subtraction ledger (estimate) + +| Cut | Removed | Added | Formats and targets | +|---|---|---|---| +| R1 | about 30 | about 70 | none | +| O1 | about 5 | about 25 | none | +| R3 | 0 | about 260 (Rust 140, C# 120); R3b about 200 | two sibling-file conventions (`.displaced.*`, `.previous`); no format change | +| O2 | about 40 | about 160 | one Odin schema, `odin.store_condition.v1` | +| O3 | about 10 | about 110 | none | +| R2 | 0 | about 40 plus tests | none (it tightens the decoders) | + +The campaign is net additive, at about +570 without R3b. It buys ends 1-5, which the operator asked for, and +removes one fatal path and one silent path. Every addition in CultLib is a capability a general consumer of the +single-file store would expect, and two of them (staging cleanup, and the header that survives truncation) are +defects. + +## 8. Operator questions + +- **Q-R1. A store file changed underneath a running Odin (decodable or not).** + - Context: Idunn fences the old Odin before granting the lease, so the other writer is never an Odin. It is a + tool, a hand edit, a restore made against the runbook, or disk corruption; P2b shows a flipped byte looks + exactly like a foreign write. Today a decodable change ends Odin, and the restart adopts the foreign file + silently. An undecodable change stops persistence forever and breaks Idunn's crossing. + - A: set the file aside (kept byte for byte, never deleted), write Odin's working set over it, keep serving, + and report it as typed state. + - B: set it aside, write the working set, then end Odin so a fresh process starts from the rewritten file. + - **Recommended: A.** B's restart protects nothing that the lease check before the write does not already + protect, and costs a route outage. +- **Q-R2. Keep one previous generation on disk (`topology.cc.previous`)?** + - Context: without it, a store that is corrupt at activation has no on-host source except "empty", and the + backup is up to 24 h old and needs the operator. It costs one hard link per write (a directory update, no data + written) and one more file of the store's size. It is a CultLib option, off by default for other consumers. + - A: yes. B: no; activation recovery goes straight to empty. + - **Recommended: A.** It is the only source that recovers publish-once documents, presence history and + correlations without the operator. Its loss bound is one write. +- **Q-R3. Activation finds nothing decodable: no usable `topology.cc`, no usable previous generation.** + - Context: presences and most catalog documents return within seconds to a minute, and correlations re-derive + in one refresh pass. Publish-once documents (if any exist, see 1.3) stay missing until their publisher + re-puts them, or until the operator restores from the backup. + - A: start empty automatically, set the corrupt file aside, and report `started-empty`. + - B: refuse to start, so Odin crash-loops until the operator restores. Every service's rendezvous is down + meanwhile. + - **Recommended: A.** A down Odin loses everything the Verse needs; an empty one loses only what nobody + republishes, and the restore path stays open. +- **Q-R4. A foreign writer keeps writing (a third set-aside within 10 minutes).** + - Context: repeated repair against a live writer makes a displaced file every second. + - A: stop writing, keep serving from memory, withhold put ACKs, and report `contested` until the operator + finds the writer. The working set survives. + - B: end Odin. Idunn restarts it, and the restart loads whatever the foreign writer last wrote. + - **Recommended: A.** B hands the store to the unknown writer. A is loud and loses nothing that is in memory. +- **Q-R5. Should a store condition change Odin's presence to `degraded`?** + - Context: a non-`active` Odin presence makes every managed dependent of Odin not Ready (`lib.rs:946`, + `dependency_evidence`). The store condition does not affect serving. + - A: no. The presence stays `active`, and the condition record is the signal. + - B: `degraded` while `unpersisted` or `contested`. + - **Recommended: A.** Otherwise a full disk becomes an outage of every dependent, and a restart that Idunn might + take in response would destroy the only complete copy. +- **Q-R6. The drills.** + - D-1: decode a copy of last night's backup `topology.cc` in a container on Yggdrasil, the same terms as Q2 for + the write map, with the copy deleted afterwards. + - D-2: after O3 deploys, a scheduled live drill that truncates the real `topology.cc` behind the lifecycle brake, + keeping a hard link of the original. + - **Recommended: yes to both**, D-1 before O3 ships and D-2 in a window the operator names. + +## 9. Follow-ups outside this map + +- **I-F1 (Idunn):** a deploy or a stop of Odin while `odin.store_condition` says `unpersisted` or `contested` + destroys the only complete copy. Idunn could read the condition from the catalog and hold the deployment + (deployment brake, never continuity). This is Idunn's decision, raised with the Idunn map. +- **I-F2 (Idunn):** "Idunn stopped restarting admitted odin" is a log line (`report_once`, `control_plane.rs:4599`), + not typed state. It belongs to Idunn's own operator surface. +- **F3 (from the write map):** the Eve lowering of `odin.store_condition` belongs to Odin's operator surface. + This map publishes the typed record only. +- **H-F1 (Heimdall):** confirm whether the deployed Heimdall re-puts its Odin documents every 60 s. O3's + outstanding list measures it after any recovery. +- **Q3 B's record in the write map:** "an ACK means durable" holds against crashes, not against a corruption at + activation, which loses at most the last write (C1). From cdc57bda2dc7b341a958137cebe0a5f6cc8b31a1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 15:40:44 +0200 Subject: [PATCH 21/34] Store recovery map: Q-R1..R6 ruled; Idunn watchdog reporting to Discord is a new campaign Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md index 5efca27..1ce37e6 100644 --- a/docs/store-recovery-cut.md +++ b/docs/store-recovery-cut.md @@ -1,5 +1,18 @@ # Odin store repair and recovery: cut map +**Operator rulings, 2026-09-30:** +- **Q-R1:** "You got it". A foreign file under a running Odin is set aside and Odin continues. +- **Q-R2:** "Yep". Keep the previous generation on disk. +- **Q-R3:** "Start empty". Nothing decodable at activation means Odin starts empty. +- **Q-R4:** "Yep, and Idunn needs better reporting capabilities so it can serve as watchdog. I ought to get a message + about this on Discord if it ever happens." + - Stop writing after the third set-aside in 10 minutes, and keep serving. + - **New requirement:** Idunn watchdog reporting that reaches the operator on Discord. It is mapped as its own + campaign and is not an Odin cut. +- **Q-R5:** "That's fine, Odin still runs after all, right?" Yes. Odin keeps serving, and the store condition is + published as its own typed record, not as a degraded presence. +- **Q-R6:** "Approved". Both drills. + Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. This map owns the means for repairing and recovering `/var/lib/gamecult/odin/topology.cc`. It follows `docs/write-pattern-cut.md` (Cuts 1-3) and starts from that campaign's landed head. From e7fbbbcadeaf05c69a59f3c40e33323015db589d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:23:56 +0200 Subject: [PATCH 22/34] Store recovery map: R1 status; Muninn reset script must move with its next CultLib bump Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md index 1ce37e6..fc08905 100644 --- a/docs/store-recovery-cut.md +++ b/docs/store-recovery-cut.md @@ -13,6 +13,15 @@ published as its own typed record, not as a degraded presence. - **Q-R6:** "Approved". Both drills. +**R1 status (Self, 2026-09-30).** Built on CultLib `hands/cultcache-read-failures` (`c0b56e06`..`b7908668`); it +is in Soul. +- **Consumer follow-up that must travel with the next Muninn CultLib bump:** + `Muninn scripts/restart-starfire-muninn.ps1:144` matches `*failed to decode MessagePack*` to trigger + `Reset-CorruptMuninnStore`. R1 removes that text, so the reset would stop firing silently. The script must match + the typed `CultCacheStoreUnreadable` kind, or the new wording, in the same change. +- C#'s truncated-store exception (`EndOfStreamException`) is an `IOException`, the same I/O-versus-undecodable + overlap. It goes with R3's C# work. + Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. This map owns the means for repairing and recovering `/var/lib/gamecult/odin/topology.cc`. It follows `docs/write-pattern-cut.md` (Cuts 1-3) and starts from that campaign's landed head. From 80af7ef041dae37d3e28b457debf35710695747b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:43:41 +0200 Subject: [PATCH 23/34] Odin map: Q3 B supersedes the Cut 2 effect row; Cut 2 deploys with Cut 3 Odin half Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index e1334a2..f8d5e42 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -19,6 +19,15 @@ approved. The copy is taken with `sudo cp` and decoded off the live path, and th - Recovery is mapped as its own pass. It covers an undecodable foreign write, a decodable one, and a failed load at activation. +**Soul on write batch 2 (2026-09-30).** Q3 B changes the expected effect. Cut 2's row in section 6 ("at most +3.5 MB/s, at most 1.05 writes/s") assumed Q3 A and is **superseded**. Under Q3 B, each peer put (presence +heartbeats included) writes the store once. +- With the live-shaped 3 MB store that is about 2.5-3 writes/s, about 7.5-9 MB/s, and 70-170 ms of the loop + blocked each second. +- Measured: route-challenge p99 461 ms and 11 kernel drops. +- After Cut 3's Odin half the store is about 80 KB and a put write is p50 about 4 ms. +- **Cut 2 therefore deploys only together with Cut 3's Odin half**, which is now being built on `hands/odin-write`. + **Q2 decode result (Eyes, 2026-09-30; full table in the CultLib session scratchpad `odin-topology-decode.md`).** - 77 records; 3,222,550 B of payload in a 3,240,078 B file. - The two "~1.55 MB records" are per-type totals. Each type is dominated by one Ghostlight operator surface: From 57cf8df1810443a44a73a2e991adb25ce3f3e53d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:20:40 +0200 Subject: [PATCH 24/34] Recovery map: R1 batch 2 Soul verdict (hold); zero-byte live stores block consumer pins Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md index fc08905..f93dc5b 100644 --- a/docs/store-recovery-cut.md +++ b/docs/store-recovery-cut.md @@ -13,8 +13,31 @@ published as its own typed record, not as a degraded presence. - **Q-R6:** "Approved". Both drills. -**R1 status (Self, 2026-09-30).** Built on CultLib `hands/cultcache-read-failures` (`c0b56e06`..`b7908668`); it -is in Soul. +**R1 status (Self, 2026-09-30).** Built on CultLib `hands/cultcache-read-failures` (`c0b56e06`..`b7908668`), then +fix batch 2 (`..2d7a3c78`). Soul's verdict on batch 2 was to hold the merge. Batch 3 is in Hands, fixing: +- **Blocker, F1 (zero-byte stores on Yggdrasil).** + - Two zero-byte stores are live: `/srv/bifrost/persona-feedback/{inbox/observations,outbox/deliveries}.cc`. + - They are pre-created by `touch` in gamecult-ops `install-bifrost-persona-feedback-native-yggdrasil.sh:42,46` + and `bootstrap-bifrost-persona-feedback-yggdrasil.sh:22,29`. + - Bifrost `persona-feedback.mjs serve` and Epiphany `epiphany-swarm` crash-loop on them once their CultLib pins + move past R1. + - **Neither pin may move until the scripts stop pre-creating stores and Self has moved the live files aside.** +- **F4.** Rust errors echo record values through serde's text (`lib.rs:753-758`). The header is echoed without bound. +- **F6.** A dangling symlink reads as absent, and the writer then replaces it. +- **F7.** The same NotFound-only rule in Python's `JsonLinesBackingStore` and in TS `pullAll`'s repair write. +- **M7.** A header-prefix fixture. +Recorded for later cuts: +- **F3 → R2/R3b.** TS, Python and C# decode the whole buffer before reading the header. A truncated newer-format + store (20..n-1 bytes) is "unsupported format" in Rust and a raw decode error elsewhere. +- **F5 → R3.** C# `ReadSnapshot` reads ENOTDIR/EISDIR as an empty store (`FileInfo.Exists`, `CultCache.cs:3439`). + C# `DirectoryMessagePackBackingStore` `File.Exists` (`:409`, `:497`) is the same. This is a live disagreement between + runtimes until R3. +- **F8 → R3b.** Python leaves its staging file behind when a write fails (`stores.py:124-128`). +- **Tooling.** + - `packages/cultcache-rs` has no committed `Cargo.lock`, so the map's `cargo test --locked` cannot run. + - The cultcache-ts interop test needs `CARGO_TARGET_DIR` unset under ygg-verify. +- **Text matches.** gamecult-ops `scripts/idunn/start-idunn-local.ps1:143` matches the same removed text as Muninn + (below). It must change with Idunn's next CultLib bump. - **Consumer follow-up that must travel with the next Muninn CultLib bump:** `Muninn scripts/restart-starfire-muninn.ps1:144` matches `*failed to decode MessagePack*` to trigger `Reset-CorruptMuninnStore`. R1 removes that text, so the reset would stop firing silently. The script must match From 59ef4da9dd9f2fb73304783e36a78d40edf27423 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:34:36 +0200 Subject: [PATCH 25/34] Write map: Soul on batch 3 (not fit to deploy); Q5 where the durable write runs Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index f8d5e42..e84fd71 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -47,6 +47,34 @@ heartbeats included) writes the store once. - The current `streampixels-web` incarnation has a correlation but no presence record; its last presence was 09-29 19:53 UTC. This matches Idunn's "not Odin-correlated" state. +**Soul on write batch 3 + Cut 3 Odin half (`f4a1f89..726e0f3`, 2026-09-30): not fit to deploy.** +- **S-1 (high; operator question Q5, below).** Every write, the fsync pair included, runs on the serving loop + (`main.rs:358` → `flush` → `compare_exchange_snapshot`; the interval flush at `:883` is on the same thread). + - A disk stall passes into route-challenge latency one for one. A probe that held the store lock measured: a 0 ms + stall gave 86 ms, 300 ms gave 288 ms, 1,500 ms gave 1,487 ms. + - Interleaved runs (ABBA) under IO pressure of 5-38% gave a challenge p99 no better with Cut 3 than without it. + - Cut 3 halves the bytes written, not the stalls. **The premise that the store's size caused the stalls was + wrong; they come from the disk.** +- **S-2 (high).** Q1's put refusal has not landed. Odin's pin `3bf1c0ce` has no put bound, so a 1.2 MB put is + ACKed, fsynced, and dropped at the next activation. It waits on the CultLib put-serve merge, then a pin bump with + a `served_record` override. +- **S-3 (medium).** `attempted` grows about 98 KB per failed write, roughly 0.9 GB/h on a full disk, which ends in + OOM. + - Fix in the owner: cultcache-rs returns a typed `NotReplaced`/`ReplacedNotDurable` write outcome (added to CultLib + R1 batch 3), and Odin keeps at most one candidate. +- **S-4 (medium).** Two admitted presences for one target, which a provider in a deploy window produces, refuse the + whole catalog (`main.rs:~628`). +- **S-5 (medium).** A RUDP transport ACK is recorded at receipt, so with pipelined puts a later put is ACKed while + an earlier one is still held. With the first datagram lost, the second is never delivered. That is data loss + under the QUIC-pivot rule; it is tracked in the CultLib ack map. `dcd36fc`'s doc line overstates. +- **S-6, S-8.** Test gaps (the limit taken through `main`; `remove_not_dirty`). +- **S-7.** The drop log echoes the key. Self ruled keys and schema ids are identities and may be logged, as in + CultLib R1. +- **Held:** F1's logic, F4's projection fallback, F5, F3's refusal, and Cut 3's activation bound, drop and removal. + Sizing uses `peer_document`, so it matches what is served. +- **Batch 4 in Hands:** S-4, S-6, S-8, the S-5 doc line, the attempted-list fixture gap, and committing Soul's + probes. S-1 waits on Q5; S-2 and S-3 wait on CultLib. + Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. Anchors are Odin `main` at `44951a1` (the deployed build, live on Yggdrasil since 2026-09-30 04:20:48 UTC) and CultLib `3bf1c0ce` (Odin's `Cargo.lock` pin). This map owns the means; there is no separate target document, because the ends are @@ -394,6 +422,19 @@ records; update the check's type list in the same pass. - **Q4. CultLib parity: port `DirectoryMessagePackBackingStore` (v4) to cultcache-rs?** It is not needed by Odin after Cuts 1-3. A reasonable consumer of the Rust runtime would expect the reference's store. **Recommended: record it as CultLib follow-up F1 with no deadline.** It is independent of this map. +- **Q5 (2026-09-30, from Soul S-1). Where does the durable write run?** Today the serving loop runs the fsync, so + any disk stall stalls route challenges, catalog reads and every other put. + - A: a writer thread with group commit. + - The loop hands each accepted put to one writer thread. The writer writes and fsyncs everything queued since + its last write in one go, then the loop sends those puts' replies. + - Challenges and reads are never behind the disk. A stall delays only put replies, and Q3 B still holds (a + reply means durable). + - It needs cultmesh-rs to let a put handler reply later (a CultLib API change) plus one Odin thread. + - B: answer route challenges on their own path. Everything else still stalls, and the challenge would report + Odin healthy while its catalog is stuck. + - C: reopen Q3 and go back to the 1 s interval. That brings back lost acknowledged puts on a crash. + + **Recommended: A.** ## 8. Related findings From 47850dfcd6d9afe83811da5bf30a9be400e0fd9a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:43:46 +0200 Subject: [PATCH 26/34] Write map: Q5 ruled A (writer thread with group commit) Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index e84fd71..ddbaea9 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -434,7 +434,8 @@ records; update the check's type list in the same pass. Odin healthy while its catalog is stuck. - C: reopen Q3 and go back to the 1 s interval. That brings back lost acknowledged puts on a crash. - **Recommended: A.** + **Recommended: A.** **RULED A by the operator, 2026-09-30: "agree on Odin writer thread".** Mapping is next + (Imagination): the cultmesh-rs deferred put reply, then the Odin writer thread with group commit. ## 8. Related findings From 95522dcd0ed7f11e0864254379ad1ab97301e00b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 19:03:15 +0200 Subject: [PATCH 27/34] Write map: Q5 cut map (C5 deferred put reply, W0 pin bump, W1 writer thread); Q5-A, Q5-B open Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 509 +++++++++++++++++++++++++++++++++++++- 1 file changed, 508 insertions(+), 1 deletion(-) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index ddbaea9..60b48cc 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -435,7 +435,514 @@ records; update the check's type list in the same pass. - C: reopen Q3 and go back to the 1 s interval. That brings back lost acknowledged puts on a crash. **Recommended: A.** **RULED A by the operator, 2026-09-30: "agree on Odin writer thread".** Mapping is next - (Imagination): the cultmesh-rs deferred put reply, then the Odin writer thread with group commit. + (Imagination): the cultmesh-rs deferred put reply, then the Odin writer thread with group commit. Mapped below in + "Q5 cut: the writer thread" (cuts C5, W0, W1; forks Q5-A and Q5-B). + +## Q5 cut: the writer thread + +**Imagination pass (Opus), 2026-09-30.** Maps the operator's ruling on Q5, "agree on Odin writer thread" (option A, +section 7). Anchors: +- Odin `hands/odin-write` at `434756e` (`crates/odin-daemon/src/main.rs`, `lib.rs`), CultLib pin `3bf1c0ce`. +- CultLib `hands/cultmesh-put-serve-bound` tip `e4e2a0b9` (`packages/cultmesh-rs/src/rudp_document_server.rs`). + Its merge base is `42ba0f08`, which already holds ack Cuts 1, 1b and 1c. +- CultLib `hands/ack-cutD` tip `e9d5ae2c` (lands on main first), and R1 batch 3 `hands/cultcache-read-failures` tip + `93ac5945` (`CultCacheStoreWriteFailed`). + +The mapping raises two forks, **Q5-A** and **Q5-B** (at the end of this section). Cuts C5 and W1 wait on Q5-A. + +### Q5.1 Body facts (probed and read) + +**Probe O1, Odin `434756e` + scratch test, Yggdrasil slot 3 (`imag-q5-odin@31953c93`).** A test process holds the +store's sibling lock for 1,500 ms, the way Soul modelled a disk stall. Full serving passes run meanwhile. A route +challenge is sent at 200 ms, then a catalog read. + +| Case | Route challenge | Catalog read | +|---|---|---| +| nothing waiting to be written | served in **39 ms** | served in 29 ms | +| only Odin's own heartbeat waiting, and the interval write due | served in **1,342 ms** | 33 ms (sent after the release) | + +- No read path takes the store lock. At `434756e` the lock is taken only by `MemoryOdinTopologyStore::load` + (`lib.rs:509`, at activation) and by `flush` (`lib.rs:612` → `compare_exchange_snapshot`). +- The stall reaches the loop through **both** writes on it: a peer put (`main.rs:363`, Soul S-1) and the interval + write of Odin's own bookkeeping (`main.rs:933-941`). The interval write stalls the loop with no peer put at all. + So the bookkeeping write must move to the writer too, not only the put write. + +**The loop still reads two other files.** They are not the store and take no lock, but they are disk reads on the +loop: +- The lease file, on every route challenge and catalog read: `raw_snapshot` `main.rs:389-409` → + `require_current_write_lease` `:546-567` → `read_process_write_lease` `:1323` (`pull_all_read_only_snapshot`). +- Idunn's projection, on every catalog read (`:608`), refresh (`:572`) and heartbeat (`:524-544`). +- Both files are small and rewritten rarely by Idunn, so they are normally served from the page cache. Probe O1 + cannot show whether a real disk stall evicts them. Only the Yggdrasil rerun (Q5.6) can. If it does, the answer + is to hold the lease record in memory and re-check it where it is decided. That is its own follow-up, not this + cut. + +**What a put's reply is on the wire today.** CultNet has no put-acknowledgement message: the list in +`cultnet-rs/src/contracts.rs` has `document_put_raw` and `error.v0`, and no put result. +- A Rust publisher (`publish_cultnet_message_to_rudp_catalog`, `cultmesh-rs/src/lib.rs:871-924` at `e4e2a0b9`) + succeeds when its reliable packet is acknowledged by the RUDP transport, and fails on a `cultnet.error.v0`. + - Its default `flush_timeout` is 300 ms, with a resend every 50 ms (`:144-157`). +- The document server sends that acknowledgement at the end of `poll_once`, after the sink returns + (`rudp_document_server.rs:468-476`). A refusal ends the session with an Error frame that acknowledges nothing + (`end_rejected_session` `:760-793`, put-serve branch). +- **So today the put's reply is the transport acknowledgement.** That is how Q3 B's "an ACK means durable" holds: + the sink writes before it returns. + +**Every packet a session sends carries acknowledgement fields** (`ack`, `ack_mask`, from `ack_state`, cultnet-rs +`rudp.rs` at main). +- The fields report every *received* reliable sequence, not only delivered ones. +- `create_packet` stamps them on each new packet: data, Pong, the end-of-poll Ack. +- A reliable packet is stored with the fields it was created with, and resent unchanged (`track_reliable`, + `due_resends`). + +Probes P1-P4 (cultnet-rs sessions at `e4e2a0b9`, scratch test `imag_q5_probe.rs`, Yggdrasil, `imag-q5-cultlib@8dcfe36a`): + +| # | Case | Result | +|---|---|---| +| P1 | two pipelined ordered puts, the first lost | the second is delivered to nobody, yet `create_ack` acknowledges it. **S-5 is still open after ack Cuts 1-1c.** `ack_state` at main is the same code, so the ack map's to-do resolves to "not closed by the pin bump" | +| P2 | a put delivered, then a Ping | the Pong acknowledges the put | +| P3 | the resend of a reply created **before** the put | does not acknowledge it | +| P3 | a reliable reply created **after** the put | acknowledges it, and so does its resend | +| P4 | a retransmit of a delivered put | not delivered again (the server's end-of-poll ack still answers it, `:468`) | + +Consequence for a reply sent later: holding back only the end-of-poll ack is not enough. +- A Pong, or a snapshot response created on the same session after the put arrived, acknowledges the put too. +- The snapshot response keeps doing so on every resend, even if its first copy were patched. +- So a session with a put awaiting its answer must be **sent nothing created after that put arrived**. Resends of + earlier packets are safe: they carry the fields they were created with. + +**The other runtimes (parity).** +- **TS** (`cultmesh-ts/src/index.ts:5891-5914` at `e4e2a0b9`): + - It acknowledges each packet at receipt (`createAckForReceived`), before the handler runs. + - It runs each session's frames through one promise chain (`record.work`), so handling is serial per session. + - `onDocumentPutRaw` may return a Promise and an application-level receipt document, which is sent when it + resolves (`:5951-5972`). + - So TS already "replies later", but at the application level, and its transport ack does not mean handled. +- **Python** (`cultmesh-py/src/cultmesh_py/server.py:407`, `_handle_raw_put` `:415`): the handler is synchronous + and returns its responses. +- Neither has a durability meaning for its ack. Parity is follow-up **F-Q5a** (Q5.8). + +**Consumers of the sink trait** (`CultMeshRudpRawDocumentSink`): +- Odin's `SinkHandle` (`main.rs:168-172`). +- Ratatoskr's catalog test (`Ratatoskr/crates/ratatoskr-core/tests/catalog.rs:29`). +- CultLib's own tests, which use closures through the blanket impl (`rudp_document_server.rs:60-67`). +- Vendored copies (StreamPixels, Heimdall) do not implement it. + +**cultcache-rs write outcome (R1 batch 3, `93ac5945`).** +- Every failed single-file write carries `CultCacheStoreWriteFailed { kind: NotReplaced | ReplacedNotDurable }`. +- Any other error from `compare_exchange_snapshot` (lock, read, decode, validation) comes before the write step, + so the file is unchanged. +- One hole: `with_exclusive_lock` (`cultcache-rs/src/lib.rs:924-932` on that branch) returns the **unlock** error + after a write that succeeded, with no marker. Odin would read that as "not replaced". Its next write would then + find its own snapshot and report `ForeignStoreWrite`, and Odin would end. + - That is rare (unlock failing), and it belongs to R1: follow-up **F-Q5b**, not this cut. + +### Q5.2 Target shape + +1. **CultLib (cut C5).** A put's sink may answer later. + - While any put of a session awaits its answer, the server sends that session nothing new. + - The one exception is resends of packets created before the put arrived. + - A snapshot request behind the pending put waits in the session's order. + - An accepted answer releases one ordinary acknowledgement. + - A refusal ends the session with the Error frame the put-serve branch sends. + - The wire does not change, and existing publishers keep "acknowledged means the sink accepted", which for Odin + is durable (Q5-A). +2. **Odin (cut W1).** One writer thread owns the store file after activation. + - The loop owns the working set, decides when to write, and answers put replies from the writer's outcomes. + - Every put accepted while a write is in flight is covered by the next single write (group commit). + - Route challenges and catalog reads never wait on the writer. +3. **Odin (cut W0), before W1.** + - A pin bump. + - S-2: a `served_record` override so Odin's admission is exact. + - S-3: the `attempted` list dies, because the typed write outcome says what the file holds. + +### Q5.3 Identity, lifecycle, authority + +| Kind | Named by | Over time | Decided by | +|---|---|---|---| +| pending put (CultLib) | a server-minted `u64` put id, unique for the server's life, never reused across session generations | created when an admitted put is offered to the sink; ends when it is answered, or when its session ends (the answer is then discarded) | the sink answers; the server alone sends | +| put reply handle (CultLib) | the put id, inside a `CultMeshRudpPutReply` | owned by whoever the sink hands it to; consumed by `accept` or `refuse`; dropping it unanswered refuses | the sink's owner | +| working-set version (Odin) | a `u64` counter in `MemoryOdinTopologyStore` | +1 on every mutation that changes a record; never reset within an activation | the loop | +| written-through version (Odin) | a `u64` in `RuntimeState` | raised only by a writer outcome `Written { version }` | the writer's outcome, applied by the loop | +| write request (Odin) | its version | at most one in flight; replaced by nothing (the next request is built when the outcome arrives) | the loop submits; the writer executes | +| what the file holds (Odin, `flushed`) | none: one snapshot | set at activation from the load; replaced on `Ok(true)` and on `ReplacedNotDurable` | the writer alone | + +"Dirty" stops being a flag (`lib.rs:502`, `dirty: Cell`): it is derived as `version > written_through`. + +### Q5.4 Cuts + +#### Cut C5. A put's sink may answer later (CultLib, cultmesh-rs, behaviour) + +- **Repo/branch:** CultLib `hands/cultmesh-put-reply-later` from `main` after the put-serve branch merges (Q5.7). + Anchors below are at put-serve `e4e2a0b9`; they move by Cut D's diff (Q5.7 names the conflicts). +- **Deletes first:** the synchronous sink call and its error mapping in the put arm, `rudp_document_server.rs:621-633` + (they are replaced, not wrapped). Nothing else is deleted. +- **Public API** (default D-1 below): + + ```rust + pub trait CultMeshRudpRawDocumentSink { + /// Answer through `reply` now or later, from any thread. + fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt, reply: CultMeshRudpPutReply); + } + // Closures keep working and answer at once: + impl Result<()>> CultMeshRudpRawDocumentSink for F { .. } + + #[must_use] pub struct CultMeshRudpPutReply { /* put id, Sender to the server */ } // Send, !Clone + impl CultMeshRudpPutReply { + pub fn accept(self); + pub fn refuse(self, reason: impl std::fmt::Display); + } + // Drop without an answer refuses: "the put was not answered". + ``` + + - `CultMeshRudpRawDocumentReceipt` (`:37-43`) is unchanged; the reply is a separate argument, so the receipt stays + `Clone + PartialEq`. + - `CultMeshRudpPollOutcome` (`:286-290`) is unchanged. A later refusal is returned as `ApplicationRejected` with + `SinkRefused(reason)` from the `poll_once` that sends it. +- **Server changes, `rudp_document_server.rs`:** + - `CultMeshRudpDocumentServer` (`:303-311`) gains: + - The answer channel (`std::sync::mpsc`: the server keeps the receiver, each reply holds a sender). + - `answered: VecDeque`, the answers taken off the channel and not yet handled. + - A next put id. + - `pending: BTreeMap`. + - `SessionEntry` (`:292-297`) gains `pending_puts: BTreeSet` and `waiting: VecDeque<(u32, CultNetMessage)>`, + the delivered frames that may not be answered yet. + - **One predicate owns "withheld":** `fn acknowledgement_withheld(&self, key) -> bool`, true while + `pending_puts` is non-empty. The S-5 fix, if the ack map takes it here, adds its clause to this function and + nowhere else. + - `poll_once`: + - Drain answers first (before `maintain`, `:366`). Each answer is looked up in `pending`; an unknown id (its + session ended) is discarded. + - Refuse → `end_rejected_session(key, reason)`, and return one `ApplicationRejected`. Further answers stay in the + channel for the next poll. + - Accept → remove the id. When the session has none left, it is released: send its ack (the rule for which + poll sends it is under the put arm below), then handle `waiting` in order. + - `:436-438`: a `result.reply` (Pong) is not sent while withheld. + - `:468-476`: the end-of-poll ack is not sent while withheld. + - `deliver_application_message` (`:565`): + - Put arm (`:572`): admission is unchanged (put-serve `:587-619`). Then mint the id, record it, and call the sink + with a reply. + - `poll_once` looks at answers again after the delivered-frame loop (`:445-466`), before the end-of-poll ack, + but handles only those for this poll's session. The rest stay queued for the next poll's first step. (Answers + move from the channel into one server-side queue, so they can be taken selectively.) A sink that answered at + once is therefore acknowledged, or refused, in the same poll with the same datagrams as today, and another + session's refusal can never cut this session's ack short. + - A release sends `create_ack()` from the drain, except for the session whose packet this poll is handling: + that session gets its ordinary end-of-poll ack, so a synchronous sink's datagrams stay exactly as they were. + - Snapshot arm (`:634`): if the session is withheld, push the request onto `waiting` and return; otherwise + answer as today. + - A put that arrives while `waiting` is non-empty also queues, so a session's frames are handled in order. A + put behind another put that is only pending is offered at once, so pipelined puts can share one write. + - Session end, wherever a session is removed (`:441`, `:486-489`, `:523`, `end_refused_session`, + `end_rejected_session`, Cut D's `end_unsendable_session`): remove its ids from `pending`, so their answers are + discarded. + - **Put this in one `remove_session(key)` helper, so no removal path can forget it.** It is the one new helper + the cut earns: there are six removal sites today. + - Doc comments: the struct doc (`:300-302`, "no background thread") stays true. The sink trait doc says what + "acknowledged" means: the sink accepted. +- **Authority map:** + - Owner of what the peer is told about a put: the server, and only through `poll_once`. The sink decides accept or + refuse; it never sends. + - Inputs: sink answers (channel); transport receipts. + - Outputs: one ack per release; one refusal per refused put's session. + - Derived: "withheld" is derived from `pending_puts`; it is not a flag. + - Forbidden writers: + - No send path to a withheld session except `maintain`'s resends. + - The sink must not be able to acknowledge by returning. The return type carries no answer, so this holds by + construction. + - Shared paths: a synchronous closure sink and a deferred sink answer through the same reply and the same drain. + There is no second code path for "answered at once". +- **Verification (CultLib, Yggdrasil):** + - Wire-level test, the observer layer: a raw client socket records every datagram the server sends to its session. + - With the reply held across at least five resend intervals, including a Ping and a snapshot request on that + session, no datagram acknowledges the put's sequence. + - After `accept()`, the next poll sends an ack that does, and then the snapshot response. + - Mutations that must fail it: sending the end-of-poll ack regardless; sending the Pong; answering the snapshot + request at once. + - Another session's snapshot request is answered while the first session's put is held (the loop is not blocked). + - `refuse()` and dropping the reply: the peer receives `cultnet.error.v0` and a goodbye, and nothing acknowledges + the put (reuse the put-serve refusal assertions). + - Two pipelined puts on one session, each with its own held reply: + - Answering the second first releases nothing. + - Answering the first then releases one ack that covers both. + - This pins that a later put's acknowledgement never leaves before an earlier one's. + - Session ends while held (peer Disconnect; a new Connect from the same key): a later `accept()` sends nothing to + the new generation, and `pending` is empty. + - Closure sink: the datagram sequence is byte-identical to the pre-cut server for accept and refuse. This pins that + the synchronous path did not change. + - An unservable put is refused before the sink sees it: the sink is never called and no reply is minted. + - `cargo mutants --in-diff` on the cut's diff; every survivor is triaged. +- **Estimate:** about +170 source, -15 source, +350 tests. No new target, dependency or wire field. + +#### Cut W0. Pin bump, exact admission and the one-candidate store (Odin, subtraction first) + +- **Repo/branch:** Odin `hands/odin-write-w0` from `hands/odin-write` (`434756e`, or batch 4's tip). It bumps the + CultLib pin to the main commit holding Cut D, put-serve, R1 batch 3 and C5 (Q5.7). +- **Deletes first:** + - `attempted` (`lib.rs:494-499`, `:525`), the retry loop over it in `flush` (`:619-625`) and its push (`:628-633`). + - The tests that describe a file holding some older failed attempt: + - `a_write_that_failed_before_replacing_keeps_every_earlier_attempt_odins` (`lib.rs:2172`). + - `the_file_may_hold_a_later_failed_attempt_not_only_the_first` (`:2205`). + - Each models a `NotReplaced` failure that nonetheless changed the file, which the typed outcome now rules out. +- **Changes:** + - `flush` (`lib.rs:612-640`) matches the error's `CultCacheStoreWriteFailed` kind (`downcast_ref`): + - `ReplacedNotDurable` → `flushed = current`, and the store stays dirty so the next write re-syncs. + - Anything else → no change. + - `a_write_that_failed_after_replacing_the_file_is_not_another_writer` (`:2141`) is re-targeted to inject + `ReplacedNotDurable` as the real outcome, not a hand-made replace. + - `a_failed_write_is_made_by_the_next_without_another_change` (`:2118`) stays. + - **S-2:** Odin's `SnapshotHandle` (`main.rs:174-181`) overrides `served_record`. It builds the envelope as + `persist_generic_document` (`:1462`) would store it, then serves it through `peer_document` (`:1400`), the + function `drop_unservable_documents` (`:1428`) sizes with. Admission and activation then use one sizing. + - A runtime-presence put keeps the default (as received). Presences are served in `public_document`'s shape and + are never near the bound, and `drop_unservable_documents` does not check them either. + - A schema that `persist_generic_document` would refuse returns an error, which the server turns into a + refusal before the sink. + - `accept_raw_document`'s doc (`main.rs:332-351`) drops its S-5 paragraph; it moves to W1's writer doc. +- **Authority map:** what the file holds is decided by the one write outcome, not by a list of guesses. + `ForeignStoreWrite` means exactly "a snapshot Odin did not write". +- **Verification:** + - Test: a `ReplacedNotDurable` failure, then a change, then a write → made over the failed snapshot. + - Test: a `NotReplaced` failure, after which another writer puts the failed snapshot into the file → `ForeignStoreWrite`. + Mutation: keeping a candidate fails it. + - Test: 1,000 failed writes leave nothing retained. This pins S-3; observe the store's retained records, or the + absence of the field. + - S-2 test: a 1.2 MB put is refused as `DocumentUnservable` and is not in the working set. + - Mutation: sizing with the received record instead of `peer_document` survives nothing. + - Negative: `rg -n attempted crates/odin-daemon/src` matches nothing. + - The pin bump carries ack Cuts 1-3 and D into Odin. The full Odin suite runs on Yggdrasil, and Soul reads this cut + as a transport change, not only a subtraction. +- **Estimate:** about -150 (including the two tests) / +60. + +#### Cut W1. The writer thread (Odin, behaviour) + +- **Repo/branch:** Odin `hands/odin-writer` from W0. +- **Deletes first:** + - `MemoryOdinTopologyStore::flush` (`lib.rs:602-640`), its `path` (`:490`) and `flushed` (`:493`) fields, and the + `dirty` flag (`:502`, `:600-602`). + - `RuntimeState::flush` (`main.rs:317-330`). + - The `self.flush()?` in `accept_raw_document` (`main.rs:363`). + - `stop`'s direct write (`main.rs:891-898`). + - The flush step of `serving_pass` (`main.rs:933-941`). + - No path is left on the loop that can write the file. That holds before the writer is added. +- **Keeps:** + - `MemoryOdinTopologyStore::load` (`lib.rs:509`): the one locked read, at activation, on the loop, before any writer + exists. `load` returns the loaded snapshot beside the store, to become the writer's `flushed`. + - `ForeignStoreWrite`, `WriteLeaseLost`, `survive` (`main.rs:979-994`), unchanged in meaning. +- **Adds, `crates/odin-daemon/src/writer.rs`** (a module, not a crate): + - `StoreWriter`: `spawn(file, flushed, lease) -> StoreWriter`, `submit(WriteRequest)`, + `try_outcome() -> Option` and `finish(self)`. + - The channel is `sync_channel(1)`. The loop submits only when nothing is in flight, so `submit` never blocks. + - `WriteRequest { version, records }`. + - `WriteOutcome { version, result }`, where `result` is one of `Written`, `Failed(text)`, `Foreign` or + `LeaseLost(text)`. + - The thread: + - Checks the lease (the check extracted from `require_current_write_lease` `main.rs:546-567` into a free + function that the loop and the writer share), then `compare_exchange_snapshot(flushed, records)`. + - Applies W0's outcome rule to `flushed`, and sends the outcome. + - It never touches reply handles, the working set or the socket. + - Mock point: the thread writes through a one-method trait `StoreFile { compare_exchange_snapshot }`, implemented + for `SingleFileMessagePackBackingStore`. Tests gate it and count it. The lock-hold (Soul's method) is used where + the real file must be on the path. +- **Changes, `main.rs`:** + - `RuntimeState` (`:141-161`) gains `writer: Option` (spawned in `try_activate` after the load, + `:284-305`), `written_through: u64`, `in_flight: Option` and `replies: VecDeque<(u64, CultMeshRudpPutReply)>`. + - `SinkHandle::accept_raw_document` (`:168-172`) takes the reply. + - `RuntimeState::accept_raw_document` (`:352-367`): + - Validates and mutates as today. + - Pushes `(store.version(), reply)`, or accepts at once when `version <= written_through` (nothing new to make + durable). + - Refuses through the reply on a validation error. + - Submits if nothing is in flight. + - A new `serving_pass` step, in place of `:933-941`, run every pass: + - `try_outcome`. On `Written { v }`: `written_through = v`, and accept every reply with version `<= v`, in queue + order. + - On `Failed`: refuse the replies with version `<= v`; they remain in the working set (refusal runs one way, as + documented at `:344-350`). + - On `Foreign` / `LeaseLost`: return the error to `survive`, which ends Odin. + - Then, if nothing is in flight and `version > written_through`, submit when a reply is waiting or when + `FLUSH_INTERVAL` is due. + - So puts never wait for the interval; bookkeeping-only changes do. + - `stop` (`:894`): + - If the lease is still current and anything is unwritten or in flight, wait for the in-flight outcome, submit the + rest, and wait for that outcome too. This is a blocking wait; it is the only one, and it happens only on the + way out. + - Answer the replies, run one `poll_server` so the acks leave, then `finish` (drop the sender and join the + thread). + - After `WriteLeaseLost` or `ForeignStoreWrite`, write nothing. Dropping the replies refuses them. + - `FLUSH_INTERVAL`'s comment (`:73-77`) and the store's doc (`lib.rs:484-488`) say: the writer thread owns the + file; puts are covered by the next write; bookkeeping waits for the interval. +- **Authority map:** + - Owner of the store file after activation: the writer thread, the only holder of a + `SingleFileMessagePackBackingStore` for the store path. It also owns what the file holds (`flushed`). + - Owner of the working set, of when to write, and of put replies: the loop (`RuntimeState`). + - Inputs: + - Writer: one request at a time, and the lease file. + - Loop: provider puts, Idunn's projection, the lease file, and writer outcomes. + - Outputs: one atomic `.cc` snapshot per request; one outcome per request; replies answered in version order. + - Derived state: + - `dirty` is derived (`version > written_through`). + - `flushed` is no longer the loop's; it lives only in the writer. + - The interval decides only bookkeeping-only writes. + - Forbidden writers: no loop code path calls `compare_exchange_snapshot`, takes the store's lock, or answers a + put reply before the outcome covering its version. + - Shared paths: peer puts, self-presence, refresh, activation-time drops and the stop write all mutate the working + set, and reach the file only through `submit`. + - Deletion line: every loop-side write (above) is deleted before `writer.rs` is added. +- **Batching rule, stated once.** The loop keeps at most one write in flight. Everything that changed while it was in + flight, puts and bookkeeping alike, goes into the next request as one snapshot of the working set. N puts accepted + during one write cost one more write and one fsync pair, not N. +- **Backpressure** (Q5-B, recommended: hold): + - A stalled writer holds put replies. The loop keeps serving, and the working set keeps admitting (the catalog + serves admitted-not-durable puts, as it does today after a failed write). + - The queue is bounded by the server's existing limits: 64 sessions and 32 MiB of admitted payload. It adds no new + bound. + - A publisher that stops waiting (the Rust default is 300 ms) disconnects. Its session ends, its answer is + discarded, and its put is still written by the next write. That is what a publisher sees today during a stall, + minus the stalled challenges. +- **Ordering.** + - Replies are answered in version order from one queue, and the server sends acks in answer order. + - Within a session, C5 releases one ack only when every pending put of the session is answered. + - So no later put's reply leaves before an earlier one's. +- **Crash mid-batch.** + - The write is one atomic replace, so the file holds the old snapshot or the new one. + - No reply of a batch is answered before its `Written` outcome, so a SIGKILL anywhere loses no acknowledged put. + - A crash after the rename but before the directory sync is `ReplacedNotDurable` if the process lives; if it dies, + nothing of that batch was acknowledged. +- **Activation.** The store is read once, by `load`, before the writer is spawned. While the writer lives, the loop + never reads the file (Cut 1's rule, unchanged). +- **Verification (Odin, Yggdrasil):** + - **Loop latency under a stall.** This pins Q5's ruling; it is Soul's S-3 probe and O1, inverted into assertions. + - Hold the store's sibling lock for 1,500 ms, with a provider put in flight and Odin's heartbeat dirty with the + interval due. + - A route challenge and a catalog read are each served within 200 ms. The put's publisher sees no ack until the + lock is released, and then does, with the put on disk. + - Mutation that must fail it: running the write synchronously on the loop, by calling the `StoreFile` directly + from `serving_pass`. + - **Group commit.** Gated `StoreFile`, held on the first write, with 10 puts from 10 publishers accepted meanwhile. + - After release, exactly 2 writes are made and all 10 publishers are acknowledged. + - Mutation: one request per put gives 11 writes. + - **Ordering.** Gated `StoreFile`; put A's write is held and put B arrives. + - B's publisher is not acknowledged before A's; the answer order is observed at the server's answer channel. + - Mutation: answering the queue from the back. + - **Durability.** `daemon_process.rs`, extending `an_acknowledged_put_survives_sigkill` (`:326`). + - 20 rounds, each a stream of puts from 4 publishers while the test holds and releases the store's lock at random + offsets. SIGKILL at a random offset. + - Every put its publisher saw acknowledged is in the file after the kill. + - Mutation: accepting replies at submit instead of at `Written`. + - **One writer.** With the writer's `StoreFile` gated shut, N serving passes, a heartbeat and a stop request make no + write from the loop: the file's identity (`file_identity`, `main.rs:3343`) is unchanged until the gate opens. + - Negative grep: `rg -n "compare_exchange_snapshot|pull_all\(" crates/odin-daemon/src` matches only `writer.rs`, + `load` and the tests. + - **Lease lost with a write queued.** No write is made, Odin ends, and the queued replies are refused. The file's + identity is unchanged. + - **Foreign write.** Odin ends without writing over it (existing `:3696`, re-targeted through the writer). + - **Stop.** SIGTERM with a put held behind the lock: after the release Odin writes, the publisher is acknowledged, + and it exits 0. With the lease lost, it writes nothing. + - Re-targeted, not deleted: `changes_inside_one_interval_are_one_write` (`:3356`, now bookkeeping-only), + `a_put_is_written_before_it_is_accepted` (`:3389`, observed at the publisher's ack), + `a_put_whose_write_is_not_made_is_refused` (`:3516`), and the `ending_*` tests (`:3728-3800`). + - `cargo mutants --in-diff` on the cut's diff. +- **Estimate:** about -120 / +230 source, +300 tests. One new module, one thread, no new crate, dependency or target. + +### Q5.5 What stays on the loop, and what does not + +| Path | Before (`434756e`) | After W1 | +|---|---|---| +| route challenge (`raw_snapshot`, exact self query) | lease-file read, sign; blocked behind any write on the loop | the same reads; never behind a write | +| catalog read (`stored_snapshot`) | working set, projection read | unchanged; never behind a write | +| peer put | mutate, then write and fsync on the loop | mutate, queue the reply, submit; the reply is sent after `Written` | +| heartbeat, refresh | mutate; the interval write on the loop | mutate; the interval write on the writer | +| store lock | taken by `flush` on the loop | taken only by `load` (activation) and the writer | + +### Q5.6 What only Yggdrasil can show + +- **Rerun Soul's ABBA** (`soul-c3/latency_cmd.txt`: `soul_loop_stall_under_live_put_rate`, base against cut, in + interleaved rounds, with `/proc/pressure/io` before and after each). + - The base is `434756e`; the cut is W1's tip. Expect route-challenge p99 to stop tracking IO pressure, while put + round trip p99 still does. + - Soul's figures at `434756e` and its base over 8 rounds, under 5-38% pressure: challenge p99 310-2,129 ms, put + round-trip p99 1,121-2,593 ms (`soul-c3/latency.log`). +- Whether the loop's remaining disk reads (the lease and projection files) ever stall under real pressure. The ABBA + run shows it as challenge p99 outliers that coincide with pressure; if they appear, open the lease-in-memory + follow-up. +- After deploy, section 6's script, plus the count of "admitted odin route continuity: timed out" per hour. + +### Q5.7 Sequencing (recommended order) + +1. **Cut D merges to CultLib main** (already slated first). +2. **The put-serve branch rebases onto it and merges.** The conflicts: + - Cut D changes `send_packet` to return `Option` and adds `end_unsendable_session`. + - Its snapshot-send failure builds `reason: format!(..)`, where the put-serve branch made `reason` a typed + `CultMeshRudpRejectionReason`. It needs a variant (for example `ResponseSendFailed(String)`), and the put-serve + Hands owns it. + - Both branches edit `poll_once`'s ack site (`:468-476`) and `end_rejected_session`. +3. **R1 batch 3 merges** (cultcache-rs only; no conflict with 1-2). +4. **C5 is cut from that main** (it rewrites the lines 1-2 touched, so it goes after them), then Soul, then merge. +5. **One Odin pin bump to that commit, as W0**, carrying S-2, S-3 and the transport cuts. Then **W1** on top. + +W0 may start on the commit after step 3 if C5 slips, at the cost of a second bump before W1. One bump is recommended: +each bump moves Odin across ack Cuts 1, 1b, 1c, 3 and D, and a single Soul pass over that transport delta is cheaper +than two. + +### Q5.8 Follow-ups this map does not own + +- **F-Q5a (parity).** TS and Python acknowledge a put at receipt, and TS's reply-later is an application receipt. + - Either they adopt C5's rule ("acknowledged means the sink accepted"), or the QUIC campaign defines an + application-level put reply for every runtime. + - QUIC has no transport ack an application can read, so it needs one anyway. Recommended owner: the QUIC campaign. + Rust C5 is then the RUDP-era contract, and TS's receipt is the seed of the QUIC shape. +- **F-Q5b (R1).** `with_exclusive_lock` returns an unlock error after a successful write, with no write-outcome + marker (`cultcache-rs/src/lib.rs:924-932` at `93ac5945`). Odin would call its own next write foreign. Fix it in + cultcache-rs: tag it, or ignore the unlock error, since dropping the file releases the lock. +- **S-5 interface.** C5 adds `acknowledgement_withheld` as the one place that decides "send no ack to this session". + The ack map's S-5 decision (probe P1) plugs in there as a second clause if it is fixed in the document server. C5 + does not fix S-5. + +### Q5.9 Subtraction ledger (estimate) + +| Cut | Removed | Added | Targets, dependencies, wire | +|---|---|---|---| +| C5 (CultLib) | ~15 | ~170 source, ~350 tests | none; sink trait signature changes (Odin, Ratatoskr test) | +| W0 (Odin) | ~150 (the `attempted` list and 2 tests) | ~60 | pin bump | +| W1 (Odin) | ~120 (every loop-side write) | ~230 source, ~300 tests | one module, one thread | + +Net source is positive: about +175. It buys the ruled capability (challenges and reads independent of the disk, with +Q3 B kept), and it removes S-3's unbounded list. + +### Q5.10 Operator questions and defaults + +- **Q5-A. What does a put's reply mean on the RUDP wire once it can come later?** + - A: the document server withholds the transport acknowledgement until the sink answers. Nothing created after the + put is sent to that session until then. The wire is unchanged, and every existing publisher (Rust + `publish_cultnet_message_to_rudp_catalog`, TS and Python publishers) keeps "acknowledged means accepted", which + for Odin is durable. + - B: a new CultNet message, a put result, sent after durability; the transport ack means received. This is a wire + change in the C# reference and four runtimes. Every Odin publisher that waits for the transport ack today must + be changed to wait for it; until then Q3 B silently weakens to "received". + - **Recommended: A.** It keeps Q3 B for every publisher with a server-only change, and RUDP is frozen except for + crash and data-loss fixes. B is the right shape under QUIC (F-Q5a), not a reason to change the RUDP wire now. + - What depends on it: C5's whole design. + - A also narrows the ack map's Q-A2 meaning for the document server: acknowledged means delivered *and answered*. + That was already true of the synchronous sink. +- **Q5-B. A writer stalled for seconds: hold put replies, or refuse with a typed error?** + - A: hold. Replies wait, bounded by the server's session and payload limits. Publishers time out on their own + clock, and the put is written when the disk returns. + - B: refuse once more than N replies wait or the oldest waits longer than T. This needs a new `cultnet.error.v0` + code (the error-contract map owns the registry) and two tunables. + - **Recommended: A.** A refusal ends the publisher's session exactly as its own timeout does, but adds a code and + two numbers, and it tells the publisher less than the truth: the put may still become durable. B earns its place + only if publishers ever wait without a timeout. +- **Default D-1 (Self, overridable). The API is a completion handle** (`CultMeshRudpPutReply`: `accept`, `refuse`, + and Drop refuses). + - The alternatives were a token with `server.answer_put(token, result)`, and a reply method keyed by session and + message id. + - Keying by session and message id is ambiguous: message ids are publisher-chosen, and a reconnect reuses the + session key. + - A token cannot be answered from another thread without routing it back to the server, and a forgotten token + leaves a session withheld until it ends. + - The handle is the Rust expression of TS's Promise-returning `onDocumentPutRaw`, and it keeps closure sinks + source-compatible. ## 8. Related findings From 07501c7c6ea342e1a1c9b8d125cea97b608cf8f7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 19:05:55 +0200 Subject: [PATCH 28/34] Write map: Q5-A and Q5-B ruled A (withhold ACK until answered; hold puts on a stall) Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index 60b48cc..97036b3 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -450,6 +450,11 @@ section 7). Anchors: The mapping raises two forks, **Q5-A** and **Q5-B** (at the end of this section). Cuts C5 and W1 wait on Q5-A. +**RULED, operator 2026-09-30: "Q5-A: A, but make a note of this for when we move on QUIC, Q5-B: A".** +- **Q5-A = A.** The server withholds the session's transport ACK until the put is answered. The wire is unchanged. + The note for QUIC (a put-result message) is in the QUIC campaign handoff. +- **Q5-B = A.** Puts are held during a writer stall, bounded by the existing session and byte limits. + ### Q5.1 Body facts (probed and read) **Probe O1, Odin `434756e` + scratch test, Yggdrasil slot 3 (`imag-q5-odin@31953c93`).** A test process holds the From 3854bb5eaf3f1b2f5f7225312f5e05dbba2ea1ac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 19:50:57 +0200 Subject: [PATCH 29/34] Recovery map: R1 batch 3 Soul (merge); symlink, fsync and encode-refusal follow-ups; live-file order Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md index f93dc5b..207f40f 100644 --- a/docs/store-recovery-cut.md +++ b/docs/store-recovery-cut.md @@ -36,6 +36,22 @@ Recorded for later cuts: - **Tooling.** - `packages/cultcache-rs` has no committed `Cargo.lock`, so the map's `cargo test --locked` cannot run. - The cultcache-ts interop test needs `CARGO_TARGET_DIR` unset under ygg-verify. +- **Soul on batch 3 (`2d7a3c78..3c9e195b`, gamecult-ops `bca7978`), 2026-09-30: merge both.** Pre-merge batch 4 is in + Hands: fix the README symlink claim (S1), close value echoes in the redb and typed-decode paths (S2) and in the + Python/TS non-string key echo (S3), make the unlock policy coherent (S9), classify encode refusals separately from + `NotReplaced` (S10), and fix a test timeout (S11). The ops branch deletes the guard test (S6) and the dead compose + layout (S5). + - **Open for R3:** writes through a symlinked store path replace the link, so a store behind a link to a mounted + volume moves into the container layer. R3 decides resolve or refuse. + - **Open for R3b:** Python and TS never fsync. After F1 their first create-by-rename is not covered by ext4 + `auto_da_alloc`, so a crash can leave zero bytes, which every post-R1 reader refuses forever. **Bifrost's CultLib + pin moves past R1 only together with R3b's TS fsync.** + - **For W0 (Odin):** classify a write failure through `CultCacheStoreWriteFailed.kind` together with its `io::Error`, + never retry on `NotReplaced` alone (S10). + - Deployed Epiphany is `d2ca6630`, using a vendored cultcache-rs (`ba6a4874`) that reads a missing store as empty. + All Epiphany units have been inactive since 2026-08-22. + - Live zero-byte stores to move aside after the ops merge, in Soul's order (setgid on inbox, then rename in place, + then relocate): `observations.cc`, `deliveries.cc`, and a third, `/var/lib/gamecult/epiphany/idunn-provider-health-admission.cc`. - **Text matches.** gamecult-ops `scripts/idunn/start-idunn-local.ps1:143` matches the same removed text as Muninn (below). It must change with Idunn's next CultLib bump. - **Consumer follow-up that must travel with the next Muninn CultLib bump:** From 1262fcdcb58358033aa3ab946b10088daac55481 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 20:49:11 +0200 Subject: [PATCH 30/34] Recovery map: live zero-byte stores moved aside; ops merged Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md index 207f40f..28f191a 100644 --- a/docs/store-recovery-cut.md +++ b/docs/store-recovery-cut.md @@ -50,6 +50,12 @@ Recorded for later cuts: never retry on `NotReplaced` alone (S10). - Deployed Epiphany is `d2ca6630`, using a vendored cultcache-rs (`ba6a4874`) that reads a missing store as empty. All Epiphany units have been inactive since 2026-08-22. + - **Done 2026-09-30 20:48 CEST (Self).** gamecult-ops merged at `3fa22aa`. The inbox is setgid 2750. All three + zero-byte stores were renamed in place and moved to `/root/zero-byte-stores-20260930/` on Yggdrasil, with + `manifest.txt` recording owner, mode and mtime. None had gained bytes. All three Bifrost units stayed active, and + persona-feedback logged nothing and did not restart. + - The compose/container lane (the compose file, two deploy scripts, the `.ps1` uploader, two runbooks) is dead: + the live Bifrost units run native `dotnet` and `node`. Deleting it is put to the operator. - Live zero-byte stores to move aside after the ops merge, in Soul's order (setgid on inbox, then rename in place, then relocate): `observations.cc`, `deliveries.cc`, and a third, `/var/lib/gamecult/epiphany/idunn-provider-health-admission.cc`. - **Text matches.** gamecult-ops `scripts/idunn/start-idunn-local.ps1:143` matches the same removed text as Muninn From 2fa1bffc38a0a798339956c7c87e3f6c51b43ce8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:13:56 +0200 Subject: [PATCH 31/34] Recovery map: R1 landed at CultLib daedfdd6 Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md index 28f191a..0902471 100644 --- a/docs/store-recovery-cut.md +++ b/docs/store-recovery-cut.md @@ -13,6 +13,11 @@ published as its own typed record, not as a degraded presence. - **Q-R6:** "Approved". Both drills. +**R1 LANDED: CultLib main `daedfdd6` (2026-09-30).** The merged tree `0e6891b3` was verified on Yggdrasil in every +runtime, including the four-runtime store interop test. That test runs close to its 30 s per-command limit under +load (`cult-cache.test.ts:1407`), so it is fragile: follow-up. Consumer pins still gate their own bumps: Bifrost only +with R3b's TS fsync; Muninn and Idunn with their error-text matches. + **R1 status (Self, 2026-09-30).** Built on CultLib `hands/cultcache-read-failures` (`c0b56e06`..`b7908668`), then fix batch 2 (`..2d7a3c78`). Soul's verdict on batch 2 was to hold the merge. Batch 3 is in Hands, fixing: - **Blocker, F1 (zero-byte stores on Yggdrasil).** From 665dc0856c524b54b41f124ec0c42468573449b0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:50:45 +0200 Subject: [PATCH 32/34] Recovery map: Bifrost container lane deleted and its legacy route retired on Yggdrasil Co-Authored-By: Claude Opus 5.5 --- docs/store-recovery-cut.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/store-recovery-cut.md b/docs/store-recovery-cut.md index 0902471..fca90d5 100644 --- a/docs/store-recovery-cut.md +++ b/docs/store-recovery-cut.md @@ -60,7 +60,12 @@ Recorded for later cuts: `manifest.txt` recording owner, mode and mtime. None had gained bytes. All three Bifrost units stayed active, and persona-feedback logged nothing and did not restart. - The compose/container lane (the compose file, two deploy scripts, the `.ps1` uploader, two runbooks) is dead: - the live Bifrost units run native `dotnet` and `node`. Deleting it is put to the operator. + the live Bifrost units run native `dotnet` and `node`. **Deleted 2026-09-30, operator: "delete the Bifrost lane".** + gamecult-ops `fdb4356`. Its live trigger, the Idunn legacy actuator route `deploy bifrost-persona-feedback`, was + retired in Idunn `7793b5b` and installed on Yggdrasil at 21:50 CEST (sha256 `d95fa08c...`; the previous binary is + at `/root/idunn-yggdrasil.pre-bifrost-retire-20260930`). The manifest and `/srv/bifrost/compose` were moved to + `/srv/retired/retired-2026-09-30/`. All three Bifrost units stayed active. **Follow-up:** persona-feedback has no + Idunn deploy route now. Give it a real Idunn binding. - Live zero-byte stores to move aside after the ops merge, in Soul's order (setgid on inbox, then rename in place, then relocate): `observations.cc`, `deliveries.cc`, and a third, `/var/lib/gamecult/epiphany/idunn-provider-health-admission.cc`. - **Text matches.** gamecult-ops `scripts/idunn/start-idunn-local.ps1:143` matches the same removed text as Muninn From f901b38fb72d074d68cdeb899291e40bf619b14c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:41:04 +0200 Subject: [PATCH 33/34] Write map: CultLib side landed (Cut D, R1, put-serve); C5 next Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index 97036b3..0c91331 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -75,6 +75,15 @@ heartbeats included) writes the store once. - **Batch 4 in Hands:** S-4, S-6, S-8, the S-5 doc line, the attempted-list fixture gap, and committing Soul's probes. S-1 waits on Q5; S-2 and S-3 wait on CultLib. +**CultLib side landed (2026-09-30).** Ack Cut D was merged at `2f06ef84`, R1 at `daedfdd6` and the put-serve bound at +`9dcb3aa1`. That unblocks the Q5 sequence: +1. R1 batch 3's typed write outcome is on main. It carries `Rejected` as well as `NotReplaced`/`ReplacedNotDurable`. + W0 must classify by kind together with the `io::Error`, never by `NotReplaced` alone. +2. **C5 next:** cultmesh-rs deferred put reply, on main. +3. **W0:** one Odin pin bump. It closes S-2 (put refusal with a `served_record` override) and S-3 (delete + `attempted`), and it brings in Cut D's and the put-serve error texts. +4. **W1:** the writer thread. + Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. Anchors are Odin `main` at `44951a1` (the deployed build, live on Yggdrasil since 2026-09-30 04:20:48 UTC) and CultLib `3bf1c0ce` (Odin's `Cargo.lock` pin). This map owns the means; there is no separate target document, because the ends are From 8e1de662c75e09e5b93005b600ccdad1cc0e8387 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 01:04:24 +0200 Subject: [PATCH 34/34] Write map: C5 Hands landed on a branch; Soul pending the operator's go Co-Authored-By: Claude Opus 5.5 --- docs/write-pattern-cut.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/write-pattern-cut.md b/docs/write-pattern-cut.md index 0c91331..3b7d93b 100644 --- a/docs/write-pattern-cut.md +++ b/docs/write-pattern-cut.md @@ -986,3 +986,23 @@ Q3 B kept), and it removes S-3's unbounded list. - **F7** `accept_raw_document` (`main.rs:269-280`) checks `activated()` but not the *current* lease before mutating. Cut 2 moves the lease check to the only write, which closes this for the file. Soul should confirm that nothing else acts on a lost lease. + +## C5 status (2026-10-01) + +**Hands done, Soul not yet run.** Drain mode: Soul waits for the operator's go. + +- **Branch:** CultLib `hands/cultmesh-deferred-put-reply`, commits `08e0c229..d708247e`, based on `9dcb3aa1`. +- **Mechanism:** the sink answers through `CultMeshRudpPutReply` with `accept()` or `refuse(reason)`. Dropping the reply unanswered refuses with `UNANSWERED_PUT_REASON`. `acknowledgement_withheld` is the single owner of the hold: while a put is unanswered, the session gets no end-of-poll ack, Pong, snapshot response or reply to a repeated Connect. +- **Verification (Hands):** + - cultmesh-rs: 21 unit and 32 integration tests pass. A byte-exact golden test also passes before the cut. + - cultnet-rs: 486 pass. + - Interop: 27 pass, 0 fail. + - cargo-mutants: 57 mutants; 0 missed, 5 timeouts, all in the `take_answers` loop. +- **Spec deviations Soul should attack:** + - A repeated Connect's ack is now withheld. + - Puts queued behind a waiting snapshot count as withheld. Snapshot responses are held in a new `responses_owed` queue: P1 pending, then S, then P2 must not acknowledge P2 early. + - Answers are checked after each message. + - A release that arrives during an unreliable packet sends its own ack. +- **Structural delta:** +406/−96 in `rudp_document_server.rs`, against a +170/−15 estimate. +- **Consumers:** Odin `SinkHandle` and Ratatoskr `tests/catalog.rs:29` break until the W0 pin bump. +- **Still open:** S-5. Its fix belongs in `acknowledgement_withheld`.