tower-rules 0.8.20

TowerRule schema — predicate + trigger + federation types for yah-tower. Zero deps on tower runtime; consumed by the rule engine, desktop UI, tower.simulate, and tests.
Documentation
use tower_rules::*;

fn assert_round_trip<T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug>(
    value: &T,
) {
    let json = serde_json::to_string(value).expect("serialize");
    let restored: T = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(value, &restored, "round-trip mismatch for {json}");
}

// ── Predicate variants ────────────────────────────────────────────────────────

#[test]
fn predicate_event_match_minimal() {
    let p = Predicate::EventMatch {
        scope: ScopeFilter::Any,
        level: None,
        target: None,
        fields: vec![],
        rate: None,
    };
    assert_round_trip(&p);
}

#[test]
fn predicate_event_match_full() {
    let p = Predicate::EventMatch {
        scope: ScopeFilter::Service {
            ident: Some(MeshIdent("yubaba.local".into())),
        },
        level: Some(LevelFilter::Error),
        target: Some(StringFilter::Glob { pattern: "raft.*".into() }),
        fields: vec![FieldFilter {
            path: "error.code".into(),
            op: FieldOp::Eq { value: serde_json::json!("quorum_lost") },
        }],
        rate: Some(RatePredicate {
            min_count: 3,
            window_ms: 5_000,
        }),
    };
    assert_round_trip(&p);
}

#[test]
fn predicate_scryer_health() {
    let p = Predicate::ScryerHealth {
        signal: HealthSignal::YubabaRaftQuorumLost,
    };
    assert_round_trip(&p);
}

#[test]
fn predicate_scryer_health_all_signals() {
    for signal in [
        HealthSignal::IngestionLag,
        HealthSignal::RingOverflow,
        HealthSignal::FederationTimeout,
        HealthSignal::YubabaRaftQuorumLost,
    ] {
        assert_round_trip(&Predicate::ScryerHealth { signal });
    }
}

#[test]
fn predicate_compound_and() {
    let p = Predicate::Compound {
        op: CompoundOp::And,
        children: vec![
            Predicate::ScryerHealth { signal: HealthSignal::YubabaRaftQuorumLost },
            Predicate::EventMatch {
                scope: ScopeFilter::Any,
                level: Some(LevelFilter::Warn),
                target: None,
                fields: vec![],
                rate: None,
            },
        ],
    };
    assert_round_trip(&p);
}

#[test]
fn predicate_compound_not() {
    let p = Predicate::Compound {
        op: CompoundOp::Not,
        children: vec![Predicate::EventMatch {
            scope: ScopeFilter::Forge { id: None },
            level: None,
            target: Some(StringFilter::Exact { value: "forge.start".into() }),
            fields: vec![],
            rate: None,
        }],
    };
    assert_round_trip(&p);
}

// ── Trigger variants ──────────────────────────────────────────────────────────

#[test]
fn trigger_agent_dispatch() {
    let t = Trigger::AgentDispatch {
        agent_class: AgentClassRef("gnome/fixer".into()),
        prompt_template: PromptTemplate(
            "Yubaba lost quorum. Context: {{event}}. Investigate and remediate.".into(),
        ),
        placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
    };
    assert_round_trip(&t);
}

#[test]
fn trigger_notification_desktop() {
    let t = Trigger::Notification {
        channels: vec![NotificationChannel::DesktopBadge],
        severity: Severity::Critical,
    };
    assert_round_trip(&t);
}

#[test]
fn trigger_notification_webhook() {
    let t = Trigger::Notification {
        channels: vec![
            NotificationChannel::DesktopBadge,
            NotificationChannel::Webhook { url: "https://hooks.example.com/alert".into() },
        ],
        severity: Severity::Warning,
    };
    assert_round_trip(&t);
}

#[test]
fn trigger_yubaba_action_restart() {
    let t = Trigger::YubabaAction {
        kind: YubabaActionKind::RestartWorkload,
        target: MeshIdent("noisetable-api.pdx".into()),
    };
    assert_round_trip(&t);
}

#[test]
fn trigger_yubaba_action_scale() {
    let t = Trigger::YubabaAction {
        kind: YubabaActionKind::Scale { replicas: 3 },
        target: MeshIdent("noisetable-worker.pdx".into()),
    };
    assert_round_trip(&t);
}

// ── FederationPolicy variants ─────────────────────────────────────────────────

#[test]
fn federation_local_only() {
    assert_round_trip(&FederationPolicy::LocalOnly);
}

#[test]
fn federation_mesh_wide() {
    assert_round_trip(&FederationPolicy::MeshWide);
}

#[test]
fn federation_mirror_set() {
    let f = FederationPolicy::MirrorSet {
        mirrors: vec!["prod-pdx".into(), "prod-fra".into()],
    };
    assert_round_trip(&f);
}

// ── Full TowerRule ────────────────────────────────────────────────────────────

#[test]
fn tower_rule_yubaba_quorum_loss() {
    let rule = TowerRule {
        schema_version: SchemaVersion::V1,
        id: RuleId("yubaba-quorum-loss".into()),
        name: "Yubaba raft quorum loss".into(),
        predicate: Predicate::ScryerHealth {
            signal: HealthSignal::YubabaRaftQuorumLost,
        },
        trigger: Trigger::Notification {
            channels: vec![NotificationChannel::DesktopBadge],
            severity: Severity::Critical,
        },
        debounce_ms: Some(30_000),
        federation: FederationPolicy::LocalOnly,
        enabled: true,
    };
    assert_round_trip(&rule);
}

#[test]
fn tower_rule_agent_dispatch_on_error_burst() {
    let rule = TowerRule {
        schema_version: SchemaVersion::V1,
        id: RuleId("db-error-burst-repair".into()),
        name: "Database error burst → gnome repair".into(),
        predicate: Predicate::EventMatch {
            scope: ScopeFilter::Service {
                ident: Some(MeshIdent("noisetable-db.pdx".into())),
            },
            level: Some(LevelFilter::Error),
            target: Some(StringFilter::Glob { pattern: "database.*".into() }),
            fields: vec![],
            rate: Some(RatePredicate { min_count: 10, window_ms: 60_000 }),
        },
        trigger: Trigger::AgentDispatch {
            agent_class: AgentClassRef("gnome/db-repair".into()),
            prompt_template: PromptTemplate(
                "Database error burst detected. Events: {{events}}. Diagnose and repair.".into(),
            ),
            placement: TaskPlacement::new(
                TaskLocation::RemoteAny { tier: TierTag("infra".into()) },
                TaskRuntime::Container,
            ),
        },
        debounce_ms: Some(300_000),
        federation: FederationPolicy::MirrorSet {
            mirrors: vec!["prod-pdx".into()],
        },
        enabled: false,
    };
    assert_round_trip(&rule);
}

// ── TaskPlacement variants ────────────────────────────────────────────────────

#[test]
fn task_runtime_round_trip() {
    assert_round_trip(&TaskRuntime::Native);
    assert_round_trip(&TaskRuntime::Container);
}

#[test]
fn task_location_local_round_trip() {
    assert_round_trip(&TaskLocation::Local);
}

#[test]
fn task_location_remote_any_round_trip() {
    assert_round_trip(&TaskLocation::RemoteAny { tier: TierTag("infra".into()) });
}

#[test]
fn task_placement_four_quadrants_round_trip() {
    for location in [TaskLocation::Local, TaskLocation::RemoteAny { tier: TierTag("infra".into()) }] {
        for runtime in [TaskRuntime::Native, TaskRuntime::Container] {
            assert_round_trip(&TaskPlacement::new(location.clone(), runtime));
        }
    }
}

#[test]
fn task_placement_local_container_wire_format() {
    let placement = TaskPlacement::new(TaskLocation::Local, TaskRuntime::Container);
    let json = serde_json::to_value(&placement).unwrap();
    assert_eq!(
        json,
        serde_json::json!({
            "location": { "kind": "local" },
            "runtime": "container",
        })
    );
}

#[test]
fn task_placement_remote_any_native_wire_format() {
    let placement = TaskPlacement::new(
        TaskLocation::RemoteAny { tier: TierTag("infra".into()) },
        TaskRuntime::Native,
    );
    let json = serde_json::to_value(&placement).unwrap();
    assert_eq!(
        json,
        serde_json::json!({
            "location": { "kind": "remote_any", "tier": "infra" },
            "runtime": "native",
        })
    );
}

// ── StringFilter variants ─────────────────────────────────────────────────────

#[test]
fn string_filter_all_variants() {
    assert_round_trip(&StringFilter::Glob { pattern: "err.*".into() });
    assert_round_trip(&StringFilter::Regex { pattern: r"^err\..+".into() });
    assert_round_trip(&StringFilter::Exact { value: "err.timeout".into() });
}

// ── FieldOp variants ──────────────────────────────────────────────────────────

#[test]
fn field_op_all_variants() {
    assert_round_trip(&FieldFilter {
        path: "code".into(),
        op: FieldOp::Eq { value: serde_json::json!(42) },
    });
    assert_round_trip(&FieldFilter {
        path: "msg".into(),
        op: FieldOp::Contains { substring: "timeout".into() },
    });
    assert_round_trip(&FieldFilter {
        path: "target".into(),
        op: FieldOp::Matches {
            filter: StringFilter::Glob { pattern: "db.*".into() },
        },
    });
    assert_round_trip(&FieldFilter {
        path: "error".into(),
        op: FieldOp::Exists,
    });
}